Compare commits

...

142 Commits

Author SHA1 Message Date
Julien Neuhart
9ec7be4687 docs: use real identifiers in the Godoc examples 2026-08-07 19:56:54 +02:00
Julien Neuhart
de7f335791 fix(outbound): keep dial pinning for hops the environment proxy declines 2026-08-07 16:37:03 +02:00
Julien Neuhart
815f586315 fix(chromium): bound the total scope matching time per conversion 2026-08-07 16:29:55 +02:00
Julien Neuhart
b71df026f6 fix(api): sanitize the output filename header 2026-08-07 16:22:42 +02:00
Julien Neuhart
8d29638b74 fix(pdfcpu): pass --force when writing over the input file 2026-08-07 15:49:53 +02:00
Julien Neuhart
63c9a36599 chore(deps): update unoconverter to v0.4.0 2026-08-07 15:40:42 +02:00
Julien Neuhart
31fa392db2 fix(libreoffice)!: return 500 when a failure is not the client's fault 2026-08-07 15:33:44 +02:00
Julien Neuhart
bb0b874d16 fix(telemetry): align resource semconv with otel sdk 1.45 detectors 2026-08-07 14:04:37 +02:00
Julien Neuhart
60f5a7b996 chore(deps): update go version in go.mod 2026-08-07 13:57:14 +02:00
Effy Teva
8b559eb699 chore(deps): update Golang and pdfcpu version 2026-08-07 13:47:41 +02:00
dependabot[bot]
50e8e44bc1 chore(deps): bump go.opentelemetry.io/otel/sdk from 1.44.0 to 1.45.0
Bumps [go.opentelemetry.io/otel/sdk](https://github.com/open-telemetry/opentelemetry-go) from 1.44.0 to 1.45.0.
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.44.0...v1.45.0)

---
updated-dependencies:
- dependency-name: go.opentelemetry.io/otel/sdk
  dependency-version: 1.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-07 13:46:53 +02:00
dependabot[bot]
b243283c9b chore(deps): bump github.com/shirou/gopsutil/v4 from 4.26.6 to 4.26.7
Bumps [github.com/shirou/gopsutil/v4](https://github.com/shirou/gopsutil) from 4.26.6 to 4.26.7.
- [Release notes](https://github.com/shirou/gopsutil/releases)
- [Commits](https://github.com/shirou/gopsutil/compare/v4.26.6...v4.26.7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-07 13:46:22 +02:00
dependabot[bot]
a0d5e93ced chore(deps): bump github.com/cucumber/godog from 0.15.1 to 0.16.0
Bumps [github.com/cucumber/godog](https://github.com/cucumber/godog) from 0.15.1 to 0.16.0.
- [Release notes](https://github.com/cucumber/godog/releases)
- [Changelog](https://github.com/cucumber/godog/blob/main/CHANGELOG.md)
- [Commits](https://github.com/cucumber/godog/compare/v0.15.1...v0.16.0)

---
updated-dependencies:
- dependency-name: github.com/cucumber/godog
  dependency-version: 0.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-07 13:44:30 +02:00
dependabot[bot]
7568fc379b chore(deps): bump go.opentelemetry.io/otel from 1.44.0 to 1.45.0
Bumps [go.opentelemetry.io/otel](https://github.com/open-telemetry/opentelemetry-go) from 1.44.0 to 1.45.0.
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.44.0...v1.45.0)

---
updated-dependencies:
- dependency-name: go.opentelemetry.io/otel
  dependency-version: 1.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-07 13:43:48 +02:00
dependabot[bot]
5b21dbf1a9 chore(deps): bump github.com/moby/moby/client from 0.5.0 to 0.5.1
Bumps [github.com/moby/moby/client](https://github.com/moby/moby) from 0.5.0 to 0.5.1.
- [Release notes](https://github.com/moby/moby/releases)
- [Changelog](https://github.com/moby/moby/blob/v0.5.1/CHANGELOG.md)
- [Commits](https://github.com/moby/moby/compare/v0.5.0...v0.5.1)

---
updated-dependencies:
- dependency-name: github.com/moby/moby/client
  dependency-version: 0.5.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-31 09:33:37 +02:00
dependabot[bot]
0a62359691 chore(deps-dev): bump prettier from 3.9.5 to 3.9.6
Bumps [prettier](https://github.com/prettier/prettier) from 3.9.5 to 3.9.6.
- [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.9.5...3.9.6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-31 09:32:02 +02:00
dependabot[bot]
7418b5cab3 chore(deps): bump github.com/prometheus/client_golang
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.24.0 to 1.24.1.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/v1.24.1/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.24.0...v1.24.1)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-version: 1.24.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-31 09:31:41 +02:00
dependabot[bot]
337a5cca64 chore(deps): bump google.golang.org/grpc from 1.81.1 to 1.82.1
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.81.1 to 1.82.1.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.81.1...v1.82.1)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.82.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 09:33:58 +02:00
dependabot[bot]
387fce2cf6 chore(deps): bump github.com/prometheus/client_golang
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.23.2 to 1.24.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/v1.24.0/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.23.2...v1.24.0)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-version: 1.24.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 09:32:26 +02:00
dependabot[bot]
c89991cc0d chore(deps-dev): bump prettier-plugin-sh from 0.18.1 to 0.19.0
Bumps [prettier-plugin-sh](https://github.com/un-ts/prettier) from 0.18.1 to 0.19.0.
- [Release notes](https://github.com/un-ts/prettier/releases)
- [Changelog](https://github.com/un-ts/prettier/blob/master/CHANGELOG.md)
- [Commits](https://github.com/un-ts/prettier/compare/prettier-plugin-sh@0.18.1...prettier-plugin-sh@0.19.0)

---
updated-dependencies:
- dependency-name: prettier-plugin-sh
  dependency-version: 0.19.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 09:32:18 +02:00
dependabot[bot]
0c8d681c35 chore(deps): bump actions/setup-go from 6 to 7
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6 to 7.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-17 16:44:40 +02:00
dependabot[bot]
3d300c2a09 chore(deps): bump actions/setup-node from 6 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-17 16:44:29 +02:00
dependabot[bot]
8aa5a4f0e7 chore(deps-dev): bump prettier from 3.9.4 to 3.9.5
Bumps [prettier](https://github.com/prettier/prettier) from 3.9.4 to 3.9.5.
- [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.9.4...3.9.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-17 16:27:45 +02:00
Julien Neuhart
a92a7fedba feat(outbound): support authenticated proxy from environment variables 2026-07-15 20:08:29 +02:00
Julien Neuhart
d0e3991d16 fix(chromium): serialize browser starts to prevent pinning proxy latch after a start timeout 2026-07-15 19:04:38 +02:00
dependabot[bot]
d67ef724f9 chore(deps): bump golang.org/x/net from 0.56.0 to 0.57.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.56.0 to 0.57.0.
- [Commits](https://github.com/golang/net/compare/v0.56.0...v0.57.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-13 10:08:40 +02:00
dependabot[bot]
68a0a9e5c1 chore(deps): bump golang.org/x/text from 0.38.0 to 0.40.0
Bumps [golang.org/x/text](https://github.com/golang/text) from 0.38.0 to 0.40.0.
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.38.0...v0.40.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-13 10:05:55 +02:00
dependabot[bot]
b5f77c3b73 chore(deps-dev): bump prettier from 3.8.4 to 3.9.4
Bumps [prettier](https://github.com/prettier/prettier) from 3.8.4 to 3.9.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.8.4...3.9.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-13 10:05:42 +02:00
dependabot[bot]
be5dcf943d chore(deps): bump github.com/shirou/gopsutil/v4 from 4.26.5 to 4.26.6
Bumps [github.com/shirou/gopsutil/v4](https://github.com/shirou/gopsutil) from 4.26.5 to 4.26.6.
- [Release notes](https://github.com/shirou/gopsutil/releases)
- [Commits](https://github.com/shirou/gopsutil/compare/v4.26.5...v4.26.6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-03 08:48:29 +02:00
dependabot[bot]
9ca1e302bd chore(deps): bump github.com/testcontainers/testcontainers-go
Bumps [github.com/testcontainers/testcontainers-go](https://github.com/testcontainers/testcontainers-go) from 0.42.0 to 0.43.0.
- [Release notes](https://github.com/testcontainers/testcontainers-go/releases)
- [Commits](https://github.com/testcontainers/testcontainers-go/compare/v0.42.0...v0.43.0)

---
updated-dependencies:
- dependency-name: github.com/testcontainers/testcontainers-go
  dependency-version: 0.43.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-29 10:54:03 +02:00
dependabot[bot]
3617bc6587 chore(deps): bump github.com/moby/moby/client from 0.4.1 to 0.5.0
Bumps [github.com/moby/moby/client](https://github.com/moby/moby) from 0.4.1 to 0.5.0.
- [Release notes](https://github.com/moby/moby/releases)
- [Changelog](https://github.com/moby/moby/blob/v0.5.0/CHANGELOG.md)
- [Commits](https://github.com/moby/moby/compare/v0.4.1...v0.5.0)

---
updated-dependencies:
- dependency-name: github.com/moby/moby/client
  dependency-version: 0.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 09:17:04 +02:00
dependabot[bot]
874adf076c chore(deps): bump github.com/moby/moby/api from 1.54.2 to 1.55.0
Bumps [github.com/moby/moby/api](https://github.com/moby/moby) from 1.54.2 to 1.55.0.
- [Release notes](https://github.com/moby/moby/releases)
- [Commits](https://github.com/moby/moby/compare/api/v1.54.2...api/v1.55.0)

---
updated-dependencies:
- dependency-name: github.com/moby/moby/api
  dependency-version: 1.55.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 09:15:11 +02:00
dependabot[bot]
9d83255ecd chore(deps): bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 09:14:56 +02:00
dependabot[bot]
3b43bfbf33 chore(deps-dev): bump prettier from 3.8.3 to 3.8.4
Bumps [prettier](https://github.com/prettier/prettier) from 3.8.3 to 3.8.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.8.3...3.8.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-16 17:55:55 +02:00
Julien Neuhart
7614964109 chore(deps): update Go dependencies, keep chromedp pinned to v0.14.2 2026-06-16 17:40:44 +02:00
Julien Neuhart
7b054da4e7 fix(chromium): generateDocumentOutline now implies generateTaggedPdf 2026-06-16 17:06:10 +02:00
Julien Neuhart
98fc403478 feat(libreoffice): block linked content from untrusted locations 2026-06-11 15:02:32 +02:00
Julien Neuhart
808a96f3d0 chore: rename factur x engine 2026-06-07 15:27:23 +02:00
Julien Neuhart
d4c20c6b39 feat(telemetry): record backing-binary versions on spans, captured at build time 2026-06-07 14:52:06 +02:00
dependabot[bot]
2050b4ae6b chore(deps): bump github.com/shirou/gopsutil/v4 from 4.26.4 to 4.26.5
Bumps [github.com/shirou/gopsutil/v4](https://github.com/shirou/gopsutil) from 4.26.4 to 4.26.5.
- [Release notes](https://github.com/shirou/gopsutil/releases)
- [Commits](https://github.com/shirou/gopsutil/compare/v4.26.4...v4.26.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-06 19:30:17 +02:00
Julien Neuhart
c0ed2dee3c feat(qpdf): record Factur-X and PDF/A attributes on traces 2026-06-06 19:26:22 +02:00
Julien Neuhart
f8905bac8c refactor: make client- and operator-facing error messages clearer and actionable 2026-06-06 19:23:46 +02:00
Julien Neuhart
3b1e4cbac4 feat(pdfengines): support owner-only encryption and document permissions 2026-06-06 14:05:46 +02:00
Julien Neuhart
287ee5be72 feat(pdfengines): redesign Factur-X API with dedicated form fields 2026-06-06 14:03:58 +02:00
Julien Neuhart
9ab39b6fca fix(libreoffice): suppress auto-generated page header for CSV conversions 2026-06-05 17:50:01 +02:00
Julien Neuhart
5558e43821 feat(pdfengines): inject Factur-X/ZUGFeRD XMP metadata 2026-06-05 17:50:01 +02:00
Oskar Sveinsen
40666529f9 fix(Dockerfile): add ca-certificates, missing in chromium-only image
The gotenberg and gotenberg-libreoffice images pull in ca-certificates as a dependency of python3-distutils-extra, but it's missing from the gotenberg-chromium image. The issue only affects webhooks, as Chromium itself uses bundled certificates.
2026-06-05 15:23:52 +02:00
Julien Neuhart
0f5e1b794a chore: expose log-std-level-case in Makefile and Compose 2026-06-04 20:47:55 +02:00
Julien Neuhart
3ab8c5920b feat(log): add log-std-level-case to control standard output level casing 2026-06-04 20:44:04 +02:00
Julien Neuhart
54853e2cbd docs(bruno): prettify 2026-06-04 20:22:16 +02:00
Julien Neuhart
5c6a5c64b5 fix(libreoffice): correct ErrRuntimeException message 2026-06-04 18:37:06 +02:00
Julien Neuhart
60482a5cdf style: apply gofmt modernization fixes 2026-06-02 19:56:50 +02:00
Julien Neuhart
a6faa892a8 refactor(pdfengines): route fallback ops through a generic runWithFallback helper 2026-06-02 19:55:47 +02:00
Julien Neuhart
4ebd977d97 feat(libreoffice): cap ErrCoreDumped retries and make them observable 2026-06-02 19:50:12 +02:00
Julien Neuhart
a82dd9f031 feat(libreoffice): add conversion size and requested pdf-format span attributes 2026-06-02 19:47:11 +02:00
Julien Neuhart
8668a1d710 feat(chromium): add per-conversion network observability with metrics and exemplars 2026-06-02 19:44:54 +02:00
Julien Neuhart
e7c8a6a50c feat(chromium): add print_to_pdf sub-span with bounded option attrs 2026-06-02 19:36:50 +02:00
Julien Neuhart
11ab93aef6 feat(chromium): add conversion I/O attributes to chromium.Pdf span 2026-06-02 19:33:41 +02:00
Julien Neuhart
1498473495 feat(api): add FileCount accessor to request Context 2026-06-02 19:31:25 +02:00
Julien Neuhart
c24883b8a1 feat(libreoffice): lift conversions-since-restart and queue depth onto libreoffice span 2026-06-02 19:30:21 +02:00
Julien Neuhart
f6069ef84f feat(chromium): lift conversions-since-restart and queue depth onto chromium spans 2026-06-02 19:29:58 +02:00
Julien Neuhart
7465166de7 feat(gotenberg): emit queue-wait and launch sub-spans in supervisor Run 2026-06-02 19:28:43 +02:00
Julien Neuhart
de572fe6b5 refactor(gotenberg): thread engine label into NewProcessSupervisor 2026-06-02 19:25:47 +02:00
Julien Neuhart
2b20399a80 feat(gotenberg): add ConversionsSinceRestart accessor to ProcessSupervisor 2026-06-02 19:23:45 +02:00
Julien Neuhart
7c630ecc6e feat(otel): emit gotenberg.startup span with engine versions 2026-06-02 19:18:50 +02:00
Julien Neuhart
bbebad1175 feat(otel): enrich resource with process, os, host, and container detectors 2026-06-02 19:16:01 +02:00
Julien Neuhart
387728f09a refactor(otel): extract shared buildResource helper 2026-06-02 19:13:38 +02:00
Julien Neuhart
65e8bf20d4 docs(otel): document sampling configuration and tail-sampling guidance 2026-06-02 19:12:03 +02:00
Julien Neuhart
e1c28d8450 feat(gotenberg): add telemetry attribute helpers for redaction and capping 2026-06-02 19:11:40 +02:00
Julien Neuhart
c63dd5ce1e feat(otel): pin trace-based exemplar filter on the meter provider 2026-06-02 19:10:51 +02:00
Julien Neuhart
7a439632da test(otel): guard OTEL_TRACES_SAMPLER is honored 2026-06-02 19:08:13 +02:00
Julien Neuhart
525102b991 refactor(libreoffice): classify Pdf errors and set span error.type 2026-06-02 19:07:26 +02:00
Julien Neuhart
8e1ab0110f refactor(chromium): classify Pdf and Screenshot errors and set span error.type 2026-06-02 19:05:34 +02:00
Julien Neuhart
a5efccd4cc feat(gotenberg): add ClassifyError with bounded error.type enum 2026-06-02 19:01:58 +02:00
Julien Neuhart
0b0e817ca5 feat(gotenberg): wrap Cmd.Exec in a process.exec client span 2026-06-02 18:59:33 +02:00
Julien Neuhart
f5b26c0b2c test(webhook): integration smoke for async webhook traceparent continuity 2026-06-02 18:56:06 +02:00
Julien Neuhart
8003109012 test(webhook): assert async conversion span shares server trace id 2026-06-02 18:53:29 +02:00
Julien Neuhart
a0ec46a764 feat(webhook): link async worker span to the originating request span 2026-06-02 18:52:32 +02:00
Julien Neuhart
b32545e589 fix(webhook): preserve trace context across async detach via WithoutCancel 2026-06-02 18:51:25 +02:00
Julien Neuhart
cb461bb1fe refactor(webhook): extract async context detach into a helper 2026-06-02 18:50:47 +02:00
Julien Neuhart
190cad0ee2 test(integration): surface container logs on failed startup 2026-05-29 15:09:50 +02:00
Julien Neuhart
1d0c9acb2a fix(otel): align resource semconv to v1.41.0 to match SDK default 2026-05-29 15:09:50 +02:00
Julien Neuhart
08181f8550 test(integration): prune orphaned networks to avoid subnet exhaustion 2026-05-29 14:32:50 +02:00
Julien Neuhart
9ea0e82525 chore(deps): update Go dependencies 2026-05-29 10:52:03 +02:00
Julien Neuhart
7967035981 fix(chromium): downgrade pinning-proxy dial logs for client-cancelled requests 2026-05-26 19:39:19 +02:00
Julien Neuhart
3d891edee4 fix(gotenberg): debounce supervisor health probes to absorb transient CDP latency 2026-05-26 19:31:09 +02:00
Julien Neuhart
320ad62e7b docs(README): add FileToPDF.dev sponsor 2026-05-26 19:29:21 +02:00
Julien Neuhart
27e70fde46 fix(chromium): stop pinning proxy when chromedp start fails 2026-05-22 09:44:25 +02:00
Julien Neuhart
2a9bf6bf11 fix(chromium): register lifecycle listeners before navigate to close race 2026-05-22 09:39:25 +02:00
Julien Neuhart
7f9c3e171c fix(chromium): downgrade pinning-proxy logs for client-cancelled requests 2026-05-21 18:30:06 +02:00
Julien Neuhart
13c5b99962 docs(README): switch logo URL [skip ci] 2026-05-12 22:00:01 +02:00
Julien Neuhart
6671b5e5d3 fix(api): serialize downloadFrom result merging to avoid concurrent map writes 2026-05-12 19:25:25 +02:00
Julien Neuhart
f9a01c9fb3 fix(gotenberg): block IPv6 prefixes that tunnel to internal IPv4 in IsPublicIP 2026-05-12 19:22:20 +02:00
dependabot[bot]
26d373854a chore(deps): bump github.com/labstack/echo/v4 from 4.15.1 to 4.15.2
Bumps [github.com/labstack/echo/v4](https://github.com/labstack/echo) from 4.15.1 to 4.15.2.
- [Release notes](https://github.com/labstack/echo/releases)
- [Changelog](https://github.com/labstack/echo/blob/v4.15.2/CHANGELOG.md)
- [Commits](https://github.com/labstack/echo/compare/v4.15.1...v4.15.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-12 10:27:54 +02:00
Julien Neuhart
93d0103585 fix(api): strip backslash separators from supplied filenames 2026-05-05 21:14:06 +02:00
Heorhii Ovcharenko
c1cdcbdaab feat(chromium): allow to specify device scale ratio for screenshots (#1543) 2026-05-04 21:16:04 +02:00
Julien Neuhart
fe1b0020b8 chore(deps): update golang to 1.26.2 2026-04-30 15:24:06 +02:00
Julien Neuhart
b47b9f45d8 docs(contributing): reorganize sections and slim PR checklist 2026-04-30 15:14:03 +02:00
Julien Neuhart
8c0ad887f2 chore(deps): update pdfcpu to v0.12.0 (#1537) 2026-04-30 14:51:05 +02:00
Julien Neuhart
45b9f32351 refactor(chromium): drop paint-callback polyfill now that chromedp is pinned 2026-04-30 14:16:59 +02:00
Julien Neuhart
ace379a92c fix(deps): pin chromedp to v0.14.2 to restore print-mode paint pipeline 2026-04-30 14:16:59 +02:00
Julien Neuhart
4998870723 feat(libreoffice): SSRF guard for embedded external content 2026-04-30 14:16:59 +02:00
Julien Neuhart
64c28dd45e fix(supervisor): retry first launch on failure (#1538) 2026-04-30 14:16:59 +02:00
Julien Neuhart
68e0f88d5b refactor(exiftool): switch from go-exiftool library to direct CLI 2026-04-24 14:40:39 +02:00
Julien Neuhart
cc97cb7e59 docs(chromium): tighten paint-polyfill always-on godoc per CONTRIBUTING style 2026-04-24 14:33:02 +02:00
Julien Neuhart
200334197f feat(chromium): always inject paint-callback polyfill to cover waitDelay users 2026-04-24 14:30:45 +02:00
Julien Neuhart
ce9c48b2b0 fix(testdata): prettify 2026-04-24 12:52:57 +02:00
Julien Neuhart
05465b3a74 docs(chromium): tighten paint-polyfill godoc per CONTRIBUTING style [skip ci] 2026-04-24 12:51:09 +02:00
Julien Neuhart
1c0ff24c4b feat(chromium): inject paint-callback polyfill when waitForExpression or waitForSelector is set 2026-04-24 12:14:52 +02:00
Julien Neuhart
8f711b0f99 Revert "feat(chromium): skip lifecycle waits when waitForExpression or waitForSelector is set"
This reverts commit 430f95f79f.
2026-04-24 11:19:14 +02:00
Julien Neuhart
430f95f79f feat(chromium): skip lifecycle waits when waitForExpression or waitForSelector is set 2026-04-24 09:28:27 +02:00
Julien Neuhart
259d80bb68 fix(test): deny-private-ips on scenario now uses http://127.0.0.1/ as the URL 2026-04-23 21:30:43 +02:00
Julien Neuhart
7a914fce65 fix(outbound)!: per-module deny-private-ips and deny-public-ips, permissive defaults 2026-04-23 20:01:27 +02:00
Julien Neuhart
a2a8c42457 fix(chromium): default-deny file:// sub-resources when no prefix is allowed 2026-04-22 07:47:19 +02:00
Julien Neuhart
4b192b1498 fix(webhook): detach async goroutine from pooled echo.Context 2026-04-22 07:47:19 +02:00
Julien Neuhart
c204cadfc5 fix(pdfengines): require uploaded stamp/watermark file for image or pdf source 2026-04-22 07:47:19 +02:00
Julien Neuhart
35f1a990a6 fix(chromium): harden outbound URL handling 2026-04-22 07:47:19 +02:00
Pieter Oliver
7729bd0590 chore(npm): restrict npm installs + package bumps for a week (try to prevent 0-days from upstream dependency changes) 2026-04-21 20:22:09 +02:00
dependabot[bot]
2980ca97a3 chore(deps): bump github.com/labstack/gommon from 0.4.2 to 0.5.0
Bumps [github.com/labstack/gommon](https://github.com/labstack/gommon) from 0.4.2 to 0.5.0.
- [Release notes](https://github.com/labstack/gommon/releases)
- [Commits](https://github.com/labstack/gommon/compare/v0.4.2...v0.5.0)

---
updated-dependencies:
- dependency-name: github.com/labstack/gommon
  dependency-version: 0.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 12:47:58 +02:00
dependabot[bot]
bb95b9b7f3 chore(deps): bump github.com/dlclark/regexp2 from 1.11.5 to 1.12.0
Bumps [github.com/dlclark/regexp2](https://github.com/dlclark/regexp2) from 1.11.5 to 1.12.0.
- [Commits](https://github.com/dlclark/regexp2/compare/v1.11.5...v1.12.0)

---
updated-dependencies:
- dependency-name: github.com/dlclark/regexp2
  dependency-version: 1.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 12:47:34 +02:00
dependabot[bot]
62d98fef79 chore(deps-dev): bump prettier from 3.8.2 to 3.8.3
Bumps [prettier](https://github.com/prettier/prettier) from 3.8.2 to 3.8.3.
- [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.8.2...3.8.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 12:47:18 +02:00
Julien Neuhart
db51f9026d docs(CONTRIBUTING.md): remove do not push instruction [skip ci] 2026-04-16 17:44:45 +02:00
hubert.lenoir
3187980ead feat: add embeds metadata 2026-04-16 17:28:16 +02:00
dependabot[bot]
eff9444294 chore(deps-dev): bump prettier from 3.8.1 to 3.8.2
Bumps [prettier](https://github.com/prettier/prettier) from 3.8.1 to 3.8.2.
- [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.8.1...3.8.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-14 22:30:29 +02:00
Julien Neuhart
999dd2db40 ci: remove thecodingmachine registry 2026-04-14 20:56:47 +02:00
Julien Neuhart
f44d59d514 fix(Dockefile): pin Chromium to version 146 on ppc64le arch 2026-04-13 20:10:47 +02:00
Julien Neuhart
092b526ab5 docs: more succint [skip ci] 2026-04-13 17:59:14 +02:00
Julien Neuhart
0b5eaff8d9 chore(deps): update Go dependencies 2026-04-13 17:45:25 +02:00
Julien Neuhart
46e190970f fix(exiftool): remove System: prefixes 2026-04-13 17:44:58 +02:00
Julien Neuhart
55d19522a8 fix(exiftool): prevent line breaks 2026-04-11 13:11:47 +02:00
Julien Neuhart
924576d3d4 fix(outboundURLs): better detaults 2026-04-11 13:05:05 +02:00
Julien Neuhart
405d8d1c2b chore(deps-dev): update package-lock.json 2026-04-10 13:43:20 +02:00
Julien Neuhart
1b1e100107 chore(deps): update Go dependencies 2026-04-10 13:42:38 +02:00
Julien Neuhart
c3b4702424 Revert "ci(ppc64le): switch to ubuntu-24.04-ppc64le-p10"
This reverts commit 866ae53436.
2026-04-10 13:36:23 +02:00
Julien Neuhart
866ae53436 ci(ppc64le): switch to ubuntu-24.04-ppc64le-p10 2026-04-09 22:00:57 +02:00
Julien Neuhart
53e6a0ecf8 Revert "test(integration): increase timeout to 90min"
This reverts commit f1d1215c7c.
2026-04-07 21:11:52 +02:00
Julien Neuhart
eab3d2da12 chore(deps): update Go dependencies 2026-04-07 21:11:41 +02:00
Julien Neuhart
3f01ca18d3 fix: better denied list 2026-04-07 21:11:41 +02:00
Julien Neuhart
405f1069c0 fix(exitool): prevent control characters 2026-04-07 21:11:41 +02:00
Julien Neuhart
f1d1215c7c test(integration): increase timeout to 90min 2026-04-07 16:21:56 +02:00
Julien Neuhart
9bf12e7cb9 fix(chromium): set PDFENGINES_CONVERT_ENGINES to empty on chromium only variants 2026-04-06 18:52:29 +02:00
Julien Neuhart
b87a6d4dfb fix(Dockerfile): re-add cURL 2026-04-06 18:46:55 +02:00
137 changed files with 13345 additions and 1934 deletions

View File

@@ -11,7 +11,7 @@ post {
}
body:multipart-form {
files: @file(../../test/integration/testdata/page-1-html/index.html)
files: @file(../test/integration/testdata/page-1-html/index.html)
~landscape: false
~printBackground: false
~scale: 1.0
@@ -50,6 +50,19 @@ body:multipart-form {
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:
~allowPrinting: false
~allowCopying: false
~allowModifying: false
~allowAnnotating: false
~allowFillingForms: false
~allowAssembling: false
~embeds: @file(../test/integration/testdata/embed_1.xml)
~embeds: @file(../test/integration/testdata/embed_2.xml)
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
~facturxXml: @file(../test/integration/testdata/embed_1.xml)
~facturxConformanceLevel: EN 16931
~facturxDocumentType: INVOICE
~facturxVersion: 1.0
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:

View File

@@ -51,6 +51,19 @@ body:multipart-form {
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:
~allowPrinting: false
~allowCopying: false
~allowModifying: false
~allowAnnotating: false
~allowFillingForms: false
~allowAssembling: false
~embeds: @file(../test/integration/testdata/embed_1.xml)
~embeds: @file(../test/integration/testdata/embed_2.xml)
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
~facturxXml: @file(../../test/integration/testdata/embed_1.xml)
~facturxConformanceLevel: EN 16931
~facturxDocumentType: INVOICE
~facturxVersion: 1.0
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:

View File

@@ -50,6 +50,19 @@ body:multipart-form {
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:
~allowPrinting: false
~allowCopying: false
~allowModifying: false
~allowAnnotating: false
~allowFillingForms: false
~allowAssembling: false
~embeds: @file(../test/integration/testdata/embed_1.xml)
~embeds: @file(../test/integration/testdata/embed_2.xml)
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
~facturxXml: @file(../test/integration/testdata/embed_1.xml)
~facturxConformanceLevel: EN 16931
~facturxDocumentType: INVOICE
~facturxVersion: 1.0
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:

View File

@@ -34,6 +34,7 @@ body:multipart-form {
~emulatedMediaType: screen
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
~omitBackground: false
~deviceScaleFactor: 1.0
}
headers {

View File

@@ -35,6 +35,7 @@ body:multipart-form {
~emulatedMediaType: screen
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
~omitBackground: false
~deviceScaleFactor: 1.0
}
headers {

View File

@@ -34,6 +34,7 @@ body:multipart-form {
~emulatedMediaType: screen
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
~omitBackground: false
~deviceScaleFactor: 1.0
}
headers {

View File

@@ -11,7 +11,7 @@ post {
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.docx)
files: @file(../test/integration/testdata/page_1.docx)
~password:
~landscape: false
~nativePageRanges:
@@ -67,6 +67,19 @@ body:multipart-form {
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:
~allowPrinting: false
~allowCopying: false
~allowModifying: false
~allowAnnotating: false
~allowFillingForms: false
~allowAssembling: false
~embeds: @file(../test/integration/testdata/embed_1.xml)
~embeds: @file(../test/integration/testdata/embed_2.xml)
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
~facturxXml: @file(../test/integration/testdata/embed_1.xml)
~facturxConformanceLevel: EN 16931
~facturxDocumentType: INVOICE
~facturxVersion: 1.0
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:

View File

@@ -11,8 +11,14 @@ post {
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
embeds: @file(../../test/integration/testdata/page_1.pdf)
files: @file(../test/integration/testdata/page_1.pdf)
embeds: @file(../test/integration/testdata/embed_1.xml)
embeds: @file(../test/integration/testdata/embed_2.xml)
embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
~facturxXml: @file(../test/integration/testdata/embed_1.xml)
~facturxConformanceLevel: EN 16931
~facturxDocumentType: INVOICE
~facturxVersion: 1.0
~downloadFrom: [{"url":"https://example.com/attachment.xml","embedded":true}]
}

View File

@@ -14,6 +14,12 @@ body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
userPassword: secret123
~ownerPassword: owner456
~allowPrinting: false
~allowCopying: false
~allowModifying: false
~allowAnnotating: false
~allowFillingForms: false
~allowAssembling: false
}
headers {

View File

@@ -0,0 +1,29 @@
meta {
name: Inject Factur-X XMP
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/factur-x
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
facturxXml: @file(../../test/integration/testdata/embed_1.xml)
facturxConformanceLevel: EN 16931
~facturxDocumentType: INVOICE
~facturxVersion: 1.0
}
headers {
~Gotenberg-Output-Filename: factur-x
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}

View File

@@ -21,6 +21,12 @@ body:multipart-form {
~bookmarks: [{"title":"Page 1","page":1},{"title":"Page 2","page":2}]
~userPassword:
~ownerPassword:
~allowPrinting: false
~allowCopying: false
~allowModifying: false
~allowAnnotating: false
~allowFillingForms: false
~allowAssembling: false
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:
@@ -31,6 +37,10 @@ body:multipart-form {
~stampOptions: {"scale":"0.5 abs","rot":"45"}
~rotateAngle: 90
~rotatePages:
~facturxXml: @file(../../test/integration/testdata/embed_1.xml)
~facturxConformanceLevel: EN 16931
~facturxDocumentType: INVOICE
~facturxVersion: 1.0
}
headers {

View File

@@ -21,6 +21,12 @@ body:multipart-form {
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:
~allowPrinting: false
~allowCopying: false
~allowModifying: false
~allowAnnotating: false
~allowFillingForms: false
~allowAssembling: false
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:
@@ -31,6 +37,10 @@ body:multipart-form {
~stampOptions: {"scale":"0.5 abs","rot":"45"}
~rotateAngle: 90
~rotatePages:
~facturxXml: @file(../../test/integration/testdata/embed_1.xml)
~facturxConformanceLevel: EN 16931
~facturxDocumentType: INVOICE
~facturxVersion: 1.0
}
headers {

View File

@@ -1,24 +1,24 @@
# Bruno API Collection
A [Bruno](https://www.usebruno.com/) collection in `.bruno/` mirrors every Gotenberg route. Update the collection when adding or updating a route.
[Bruno](https://www.usebruno.com/) collection mirroring every Gotenberg route. Update the collection when adding or modifying a route.
## Structure
```
.bruno/
├── bruno.json # Collection config
├── collection.bru # Collection-level defaults (Gotenberg-Trace header)
├── bruno.json # Collection config
├── collection.bru # Collection-level defaults (Gotenberg-Trace header)
├── environments/
│ ├── Local.bru # baseUrl: http://localhost:3000
│ └── Demo.bru # baseUrl: https://demo.gotenberg.dev
├── Health & Info/ # GET routes
├── Chromium/Convert/ # POST routes grouped by module
│ ├── Local.bru # baseUrl: http://localhost:3000
│ └── Demo.bru # baseUrl: https://demo.gotenberg.dev
├── Health & Info/ # GET routes
├── Chromium/Convert/ # POST routes grouped by module
├── Chromium/Screenshot/
├── LibreOffice/
└── PDF Engines/<Feature>/ # One folder per feature (Merge, Split, Rotate, )
└── PDF Engines/<Feature>/ # One folder per feature (Merge, Split, Rotate, ...)
```
## `.bru` File Format
## `.bru` file format
```bru
meta {
@@ -51,12 +51,12 @@ headers {
## Conventions
- **Mandatory fields** have no prefix; **optional fields** use the `~` prefix (disabled by default in Bruno).
- **File references** use relative paths to `test/integration/testdata/`.
- **Webhook and output filename headers** appear on every POST route as optional (`~`).
- **One `.bru` file per request.** For routes with read/write variants (e.g., bookmarks, metadata), create separate files in the same folder.
- Mandatory fields have no prefix. Optional fields use `~` (disabled by default in Bruno).
- File references use relative paths to `test/integration/testdata/`.
- Webhook and output filename headers appear on every POST route as optional (`~`).
- One `.bru` file per request. For routes with read/write variants (e.g., bookmarks, metadata), create separate files in the same folder.
## Checklist When Adding/Updating a Route
## Checklist
1. Create or update the `.bru` file in the matching folder under `.bruno/`.
2. Include all form fields from the route handler. Check `FormData*` calls in the route function.

View File

@@ -12,7 +12,15 @@ updates:
directory: "/"
schedule:
interval: "weekly"
ignore:
# Held at v0.14.2: v0.15.x breaks the headless print-mode paint pipeline
# (rAF / ResizeObserver / IntersectionObserver stop firing, blank charts).
# See https://github.com/gotenberg/gotenberg/issues/1535.
- dependency-name: "github.com/chromedp/chromedp"
- dependency-name: "github.com/chromedp/cdproto"
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 7

View File

@@ -23,7 +23,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Build and push
id: build_push
@@ -50,7 +50,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Build and push
id: build_push
@@ -77,7 +77,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Build and push
id: build_push
@@ -104,7 +104,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Build and push
id: build_push
@@ -131,7 +131,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Build and push
id: build_push
@@ -154,7 +154,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Merge
uses: ./.github/actions/merge
@@ -162,7 +162,6 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_ppc64le.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }}"
alternate_registry: thecodingmachine
- name: Merge Chromium
uses: ./.github/actions/merge
@@ -170,7 +169,6 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags_chromium }},${{ needs.release_386.outputs.tags_chromium }},${{ needs.release_ppc64le.outputs.tags_chromium }},${{ needs.release_arm64.outputs.tags_chromium }},${{ needs.release_arm_v7.outputs.tags_chromium }}"
alternate_registry: thecodingmachine
- name: Merge LibreOffice
uses: ./.github/actions/merge
@@ -178,7 +176,6 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags_libreoffice }},${{ needs.release_386.outputs.tags_libreoffice }},${{ needs.release_ppc64le.outputs.tags_libreoffice }},${{ needs.release_arm64.outputs.tags_libreoffice }},${{ needs.release_arm_v7.outputs.tags_libreoffice }}"
alternate_registry: thecodingmachine
- name: Merge AWS Lambda
uses: ./.github/actions/merge
@@ -186,7 +183,6 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags_aws_lambda }},${{ needs.release_arm64.outputs.tags_aws_lambda }}"
alternate_registry: thecodingmachine
- name: Merge AWS Lambda Chromium
uses: ./.github/actions/merge
@@ -194,7 +190,6 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.release_arm64.outputs.tags_aws_lambda_chromium }}"
alternate_registry: thecodingmachine
- name: Merge AWS Lambda LibreOffice
uses: ./.github/actions/merge
@@ -202,7 +197,6 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.release_arm64.outputs.tags_aws_lambda_libreoffice }}"
alternate_registry: thecodingmachine
- name: Clean
uses: ./.github/actions/clean

View File

@@ -21,10 +21,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
@@ -38,15 +38,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version-file: .node-version
- name: Install Dependencies
run: npm i
run: npm ci --ignore-scripts
- name: Run linters
run: make lint-prettier
@@ -59,10 +59,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
@@ -87,7 +87,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Build, test and push
id: build_test_push
@@ -117,7 +117,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Build, test and push
id: build_test_push
@@ -147,7 +147,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Build, test and push
id: build_test_push
@@ -177,7 +177,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Build, test and push
id: build_test_push
@@ -207,7 +207,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Build, test and push
id: build_test_push
@@ -253,7 +253,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Merge
uses: ./.github/actions/merge
@@ -322,7 +322,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Build, test and push
id: build_test_push
@@ -351,7 +351,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Build, test and push
id: build_test_push
@@ -380,7 +380,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Build, test and push
id: build_test_push
@@ -409,7 +409,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Build, test and push
id: build_test_push
@@ -438,7 +438,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Build, test and push
id: build_test_push
@@ -460,7 +460,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Merge
uses: ./.github/actions/merge
@@ -468,7 +468,6 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_ppc64le.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }}"
alternate_registry: thecodingmachine
- name: Merge Chromium
uses: ./.github/actions/merge
@@ -476,7 +475,6 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags_chromium }},${{ needs.edge_386.outputs.tags_chromium }},${{ needs.edge_ppc64le.outputs.tags_chromium }},${{ needs.edge_arm64.outputs.tags_chromium }},${{ needs.edge_arm_v7.outputs.tags_chromium }}"
alternate_registry: thecodingmachine
- name: Merge LibreOffice
uses: ./.github/actions/merge
@@ -484,7 +482,6 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags_libreoffice }},${{ needs.edge_386.outputs.tags_libreoffice }},${{ needs.edge_ppc64le.outputs.tags_libreoffice }},${{ needs.edge_arm64.outputs.tags_libreoffice }},${{ needs.edge_arm_v7.outputs.tags_libreoffice }}"
alternate_registry: thecodingmachine
- name: Merge AWS Lambda
uses: ./.github/actions/merge
@@ -492,7 +489,6 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags_aws_lambda }},${{ needs.edge_arm64.outputs.tags_aws_lambda }}"
alternate_registry: thecodingmachine
- name: Merge AWS Lambda Chromium
uses: ./.github/actions/merge
@@ -500,7 +496,6 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.edge_arm64.outputs.tags_aws_lambda_chromium }}"
alternate_registry: thecodingmachine
- name: Merge AWS Lambda LibreOffice
uses: ./.github/actions/merge
@@ -508,7 +503,6 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.edge_arm64.outputs.tags_aws_lambda_libreoffice }}"
alternate_registry: thecodingmachine
- name: Clean
uses: ./.github/actions/clean

View File

@@ -14,7 +14,7 @@ jobs:
continue-on-error: true
steps:
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Cleanup
uses: ./.github/actions/clean

View File

@@ -1 +1 @@
24.11.0
24.15.0

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
min-release-age=7

View File

@@ -1,39 +1,59 @@
# Contributing to Gotenberg
**Gotenberg** is a Docker-based API for converting documents to PDF. It is a widely used production dependency. Stability and backward compatibility are paramount. When in doubt about whether a change is breaking, flag it rather than assuming it's safe.
Gotenberg is a Docker-based API for converting documents to PDF. Two rules override everything else:
## Getting Started
- **Backward compatibility.** Never rename or remove CLI flags, environment variables, API form fields, or HTTP endpoints without discussion.
- **Defensive programming.** Assume input is malformed, handle errors explicitly, never panic.
### Prerequisites
## Toolchain
- Go (see version in `go.mod`)
- Module: `github.com/gotenberg/gotenberg/v8`
- Go: see version in `go.mod`
- Docker
- Node.js (see version in `.node-version`), for Prettier linting
- Node.js (see `.node-version`), for Prettier linting
- [golangci-lint](https://golangci-lint.run/) v2+
### Build and Run
## Before you start
```bash
make build # Build the Docker image
make run # Run a local Gotenberg container
For non-trivial changes, open an issue or a draft PR first. Describe what needs to change, the proposed solution (files to modify, interface changes, form fields), and which integration test tags are affected.
One thing per PR. Keep features, bug fixes, and refactoring in separate PRs.
When adding a feature or route, write the Gherkin scenario before the Go code, and plan to update the Bruno collection (`.bruno/`) if a route changes.
## Project layout
```
cmd/gotenberg/ -> Entry point only (wiring/startup). No business logic.
pkg/gotenberg/ -> Core module system, interfaces, utilities, mocks.
pkg/modules/ -> Feature modules (api, chromium, libreoffice, pdfengines, etc.).
pkg/standard/ -> Wires all standard modules together via imports.
test/integration/ -> Gherkin feature files + Go test infrastructure.
build/ -> Dockerfile, fonts, Chromium config.
.bruno/ -> Bruno API collection (mirrors every route).
```
### Development Loop
Key interfaces live in `pkg/gotenberg/`: `Module`, `Provisioner`, `Validator`, `Debuggable`. Every module implements `Descriptor()` and self-registers via `init()`.
```bash
# Write your code, then:
make fmt # Format Go code
make prettify # Format non-Go files (Markdown, YAML, etc.)
make lint # Lint Go code (zero errors permitted)
make lint-prettier # Lint non-Go files
make test-unit # Run unit tests
make build # Build the Docker image (required before integration tests)
make test-integration # Run all integration tests
make telemetry # Start OpenTelemetry collector and OpenObserve
make down # Stop all compose containers
```
## Setup and Makefile
To run only the integration tests relevant to your change:
All build and verification tasks go through the Makefile. Do not run `go` commands directly unless debugging a specific package.
| Command | Purpose | When to use |
| ----------------------- | ------------------------------------------------ | ------------------------------------------------------------------------ |
| `make build` | Build the Gotenberg Docker image | Before integration tests or manual testing |
| `make run` | Run a Gotenberg container via `docker compose` | Manual testing. Flags configured via Makefile variables and compose.yaml |
| `make telemetry` | Start an OpenTelemetry collector and OpenObserve | When testing telemetry locally |
| `make down` | Stop all compose containers | After manual testing |
| `make godoc` | Serve GoDoc at `localhost:6060` | To verify documentation |
| `make fmt` | Format Go code | Before committing |
| `make lint` | Lint Go code (zero errors permitted) | Before committing |
| `make prettify` | Format non-Go files (Markdown, YAML, JSON) | Before committing |
| `make lint-prettier` | Lint non-Go files | Before committing |
| `make test-unit` | Run unit tests | Before committing |
| `make test-integration` | Run all integration tests (40 min timeout) | Before committing |
Run only the integration test tag(s) relevant to your change rather than the full suite:
```bash
make test-integration TAGS=health
@@ -41,191 +61,135 @@ make test-integration TAGS=chromium-convert-html
make test-integration TAGS="merge,split"
```
## Submitting a Pull Request
## Code conventions
For non-trivial changes, outline your approach before writing code. Open an issue or draft PR describing:
### Module system
- What needs to change and why.
- The proposed solution, with enough detail to implement (files to modify, interface changes, form fields, etc.).
- Which integration test tags will be affected and what new scenarios are needed.
Gotenberg uses a self-registering module architecture inspired by CaddyServer. Each module lives in `pkg/modules/<name>/`, implements at minimum `gotenberg.Module` (`Descriptor()`), and self-registers via `init()`. Wiring happens through `pkg/standard/`.
Before opening (or marking ready) a PR, verify:
Determine if a feature belongs in an existing module before creating a new one. Only create a new module for a genuinely separate concern.
1. Code compiles: `make build`
2. Code is formatted: `make fmt` and `make prettify`
3. All linters pass: `make lint` and `make lint-prettier`
4. Integration tests pass: `make test-integration` (at minimum, the relevant tags)
5. Unit tests pass: `make test-unit`
6. All exported symbols and new packages have GoDoc comments
7. Bruno collection is updated (if routes were added or modified)
The `cmd/gotenberg/` package is strictly for wiring and startup. No business logic.
Review your changes against the [Review Checklist](#review-checklist) before submitting.
### Backward compatibility
### Guidelines
CLI flags, environment variables, API form fields, HTTP endpoints, and default values that alter existing behavior must not change without discussion. Deprecate old names with `fs.MarkDeprecated()` and register both the old and new names side by side.
- **One thing per PR.** Keep features, bug fixes, and refactoring in separate PRs.
- **Backward compatibility matters.** Do not rename or remove existing CLI flags, environment variables, or API form fields without discussion.
- **Integration tests first.** When adding a feature or route, start by writing the Gherkin scenario in `test/integration/features/`. See [`test/integration/README.md`](test/integration/README.md) for the full reference.
- **Unit tests** when applicable: table-driven tests in `*_test.go` files using mocks from `pkg/gotenberg/mocks.go`.
If a change violates backward compatibility, flag it as a breaking change in the PR description.
### Commit Conventions
### Error handling
If committing, follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
- Wrap every error with context: `fmt.Errorf("description: %w", err)`.
- Never swallow errors silently.
- Match errors with `errors.Is`, never `strings.Contains`.
- No panics in production code paths.
- Validate input defensively.
```
<type>(<scope>): <description>
```
### Error messages
Common types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `ci`, `build`. The scope should match the module or area of the change (e.g., `chromium`, `pdfengines`, `api`).
Client- and operator-facing error messages state what failed, why when non-obvious, and how to fix it when a fix exists. Internal errors (the wrapped `fmt.Errorf` chains that only reach logs) are exempt; keep them precise and technical.
Stage only the files related to the change. Do not use `git add -A` or `git add .`.
- Client (HTTP response body): name the offending form field and its valid values. Never return a bare `http.StatusText()`.
- Operator (startup, `Provision`, `Validate`): name the environment variable or flag to set, plus the path or value checked.
- Security and filtering errors stay generic for clients. Don't reveal allow/deny lists or private-IP policy. Log the specific reason for operators.
- No hedging ("while others may have failed"). No raw `os.Stat` or exec output in the human-facing remedy.
---
### Logging
## Core Principles
Use `gotenberg.Logger(mod)` to get the module's slog logger during `Provision()`. All log calls must be context-aware: `logger.DebugContext(ctx, msg)`, `logger.InfoContext(ctx, msg)`, `logger.ErrorContext(ctx, msg)`. This propagates trace/span IDs into structured logs when OpenTelemetry is active.
- **Backward compatibility is law.** See the [Review Checklist](#review-checklist) for the full list of what must not change.
- **Defensive programming.** Assume input is malformed. Handle errors explicitly. Never panic.
- **Atomic commits.** One feature or fix per PR. Isolate refactoring from feature work.
- **Idiomatic Go.** Follow "Effective Go" principles. All exported symbols must have GoDoc comments starting with their name.
### Telemetry
## Project Layout and Navigation
External tool calls (Chromium, LibreOffice, PDF engines, webhooks, downloads) must create OTEL spans with `trace.SpanKindClient` and `semconv.ServerAddress("toolname")`. Use `gotenberg.Tracer()` and `gotenberg.Meter()` for traces and metrics.
```
cmd/gotenberg/ → Entry point only (wiring/startup). No business logic.
pkg/gotenberg/ → Core module system, interfaces, utilities, mocks.
pkg/modules/ → Feature modules (api, chromium, libreoffice, pdfengines, etc.).
pkg/standard/ → Wires all standard modules together via imports.
test/integration/ → Gherkin feature files + Go test infrastructure.
build/ → Dockerfile, fonts, Chromium config.
.bruno/ → Bruno API collection (mirrors every route).
```
### Import ordering
Key interfaces live in `pkg/gotenberg/`: `Module`, `Provisioner`, `Validator`, `Debuggable`. Every module implements `Descriptor()` and self-registers. When adding features, determine if they belong in an existing module or require a new one.
Enforced by `gci`: standard library, then third-party, then `github.com/gotenberg/gotenberg/v8`. Three groups separated by blank lines.
- The integration test infrastructure in `test/integration/scenario/` is well-structured. Read `scenario.go` and `containers.go` to understand the Gherkin step definitions before writing new tests.
- Mocks for all major interfaces are in `pkg/gotenberg/mocks.go`. Use them for unit tests rather than creating new ones.
- When making changes, run only the relevant integration test tag rather than the full suite (40min timeout).
- Telemetry infrastructure lives in `pkg/gotenberg/telemetry.go` (global Logger, Tracer, Meter) and `pkg/gotenberg/internal/` (log handlers, OTEL SDK init). HTTP semantic conventions are in `pkg/gotenberg/semconv/`.
## Documentation conventions
## Makefile: the Only Build Interface
### Tone
All build and verification tasks go through the Makefile. Do not run `go` commands directly unless debugging a specific package. The [Development Loop](#development-loop) covers the commands used during daily work. Additional commands:
| Command | Purpose | When to use |
| ---------------- | --------------------------------------------- | ---------------------------------------------------------------------------- |
| `make run` | Run Gotenberg container via `docker compose` | Manual testing. Flags are configured via Makefile variables and compose.yaml |
| `make telemetry` | Start OpenTelemetry collector and OpenObserve | When testing telemetry locally |
| `make down` | Stop all compose containers | After manual testing |
| `make godoc` | Serve GoDoc at `localhost:6060` | To verify documentation |
## Module System
Gotenberg uses a self-registering module architecture inspired by CaddyServer. Each module:
- Lives in `pkg/modules/<name>/`
- Implements the `gotenberg.Module` interface (at minimum `Descriptor()`)
- May also implement `gotenberg.Provisioner`, `gotenberg.Validator`, or `gotenberg.Debuggable`
- Self-registers via `init()` and is wired through `pkg/standard/`
When adding a feature, first determine if it belongs in an existing module. Only create a new module if the feature represents a genuinely separate concern.
## Coding Patterns
- **Error handling:** Always wrap errors with context using `fmt.Errorf("description: %w", err)`. Never swallow errors silently.
- **Import ordering:** Enforced by `gci`: standard library, then third-party, then `github.com/gotenberg/gotenberg/v8`. Three groups separated by blank lines.
- **Mocks:** Comprehensive mock implementations for all major interfaces live in `pkg/gotenberg/mocks.go`. Use these for unit tests.
- **Logging:** Use `gotenberg.Logger(mod)` to get the module's slog logger during `Provision()`. All log calls must be context-aware: `logger.DebugContext(ctx, msg)`, `logger.InfoContext(ctx, msg)`, `logger.ErrorContext(ctx, msg)`. This propagates trace/span IDs into structured logs when OpenTelemetry is active.
- **Telemetry:** External tool calls (Chromium, LibreOffice, PDF engines, webhooks, downloads) must create OTEL spans with `trace.SpanKindClient` and `semconv.ServerAddress("toolname")`. Use `gotenberg.Tracer()` and `gotenberg.Meter()` for traces and metrics respectively.
- **No business logic in `cmd/`:** The `cmd/gotenberg/` package is strictly for wiring and startup.
## Documentation
### Writing Style
- **Short, declarative sentences.** Say what it does, then stop.
- **Lead with the action.** "Validates font embedding" not "This function validates font embedding".
- **Active voice.** "Gotenberg checks the profile" not "The profile is checked by Gotenberg".
- **No em dashes.** Use a period, colon, or comma instead.
- **No "we" hedging.** "Don't..." not "We do not recommend...".
- Short, declarative sentences. Say what it does, then stop.
- Lead with the action. "Validates font embedding", not "This function validates font embedding".
- Active voice. "Gotenberg checks the profile", not "The profile is checked by Gotenberg".
- No em dashes. Use a period, colon, or comma.
- No "we" hedging. "Don't...", not "We do not recommend...".
### Godoc
All exported types and functions require Godoc comments. Start with the identifier name:
Every exported type and function has a Godoc comment starting with its identifier name:
```go
// Violation records a single rule violation with context.
type Violation struct { ... }
// OutboundDecision is the result of validating an outbound URL via
// [DecideOutbound]. ...
type OutboundDecision struct { ... }
// ValidatePDFA audits the document against a PDF/A profile.
func ValidatePDFA(ctx context.Context, ...) ([]error, error)
// DialPinned dials each addr in turn until one connects, returning the
// first successful connection or the last error. ...
func DialPinned(ctx context.Context, network string, addrs []netip.Addr, port string) (net.Conn, error)
```
Each package should have a `doc.go` with a `// Package foo ...` comment.
Reference other identifiers with square brackets so pkg.go.dev renders them as links:
Each package should have a `doc.go` with a `// Package foo ...` comment:
```go
// ValidatePDFA returns violations as []error where each element is a
// [Violation] value. See [Rule] for the structured rule fields.
// The document must be opened via [pdf.Open] with an [io.ReaderAt].
// Package api manages a LibreOffice instance via the UNO API.
package api
```
This works for same-package identifiers (`[Violation]`), other packages (`[io.Reader]`), and methods (`[Reader.Open]`).
Reference identifiers with `[Name]` brackets for pkg.go.dev linking:
### Code Comments
```go
// Callers pass the Pinned slice from [OutboundDecision] so that the dial
// targets exactly the IPs that [DecideOutbound] resolved, preventing DNS
// rebinding between validation and connect.
```
- Explain _why_, not _what_. The code shows what; the comment explains the non-obvious reasoning.
### Code comments
- Explain _why_, not _what_.
- No numbered step comments (`// 1. Do X`, `// 2. Do Y`).
- No section dividers with numbers (`// --- 8. Foo ---`). Plain dividers are fine for major boundaries (`// --- VeraPDF ---`).
- No section dividers with numbers (`// --- 8. Foo ---`). Plain dividers are fine for major boundaries.
- No noise comments that restate the code (`// Check if err is nil`, `// Return results`).
- Reference spec clauses where relevant (`// Per ISO 32000-2, Table 116...`).
- Mark technical debt with `// TODO: [context]`.
- Mark debt with `// TODO: [context]`.
---
## Testing
## Review Checklist
### Unit tests
### Backward Compatibility
Table-driven tests in `*_test.go` files. Use the comprehensive mock implementations in `pkg/gotenberg/mocks.go` rather than rolling new ones.
- [ ] No existing CLI flags renamed or removed
- [ ] No existing environment variables renamed or removed
- [ ] No existing API form fields renamed or removed
- [ ] No existing HTTP endpoints changed or removed
- [ ] No changes to default values that alter existing behavior
- [ ] Deprecated flags have both old and new names registered, with `fs.MarkDeprecated()`
### Integration tests
If any of these are violated, the change **must** be flagged as a breaking change.
Gherkin (BDD) via Godog with `testcontainers-go` for Docker orchestration. Feature files live in `test/integration/features/`; step definitions live in `test/integration/scenario/`. Read `scenario.go` and `containers.go` before writing new tests.
### Linting Standards
`make build` is required before running integration tests. The full suite has a 40-minute timeout, so run only the tag(s) relevant to your change.
The `.golangci.yml` enforces strict rules including: `gosec`, `govet`, `errcheck`, `staticcheck`, `dupl`, `bodyclose`, `exhaustive`, `errname`, `sloglint`, `gocritic`, and more. Zero linting errors are permitted.
## Pull requests
Formatters enforce `gci`, `gofmt`, `gofumpt`, `goimports` (see import ordering in [Coding Patterns](#coding-patterns)).
### Commits
### Code Quality
[Conventional Commits](https://www.conventionalcommits.org/): `<type>(<scope>): <description>`.
- Errors are wrapped with context: `fmt.Errorf("description: %w", err)`. No swallowed errors.
- No business logic in `cmd/`.
- No panics in production code paths.
- Input is validated defensively.
- New features belong in the correct module (or justify a new one).
Common types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `ci`, `build`. The scope matches the module or area of the change (e.g., `chromium`, `pdfengines`, `api`).
### Documentation
Stage specific files. Never `git add -A` or `git add .`.
- Every exported function, type, constant, and variable has a Godoc comment starting with its name (see [Godoc](#godoc)).
- New packages include a `doc.go` file.
- `README.md` is not modified unless explicitly requested.
- All documentation follows the [Writing Style](#writing-style) and [Code Comments](#code-comments) guidelines.
### Checklist
---
Before opening the PR, confirm:
## Scoped Guidelines
- [ ] No backward-compatibility regression. See [Backward compatibility](#backward-compatibility).
- [ ] Code conventions met (error wrapping, logging, telemetry, import ordering, no panics, no business logic in `cmd/`). See [Code conventions](#code-conventions).
- [ ] Documentation conventions met (Godoc on every exported identifier, `doc.go` for new packages, tone). See [Documentation conventions](#documentation-conventions).
- [ ] `make fmt && make lint && make prettify && make lint-prettier` pass with zero warnings.
- [ ] `make test-unit` passes.
- [ ] Relevant `make test-integration TAGS=...` passes.
- [ ] Bruno collection updated if routes were added or modified.
Some areas of the codebase have their own README with detailed instructions:
## Further reading
| Area | README | Covers |
| ----------------- | ---------------------------------------------------------------------- | --------------------------------------------------------- |
| Integration tests | [`test/integration/README.md`](test/integration/README.md) | Gherkin step reference, available tags, writing new tests |
| Bruno collection | [`.bruno/README.md`](.bruno/README.md) | `.bru` file format, conventions, route update checklist |
| PDF engines | [`pkg/modules/pdfengines/README.md`](pkg/modules/pdfengines/README.md) | Adding new engine features (Makefile variable and flag) |
- [`test/integration/README.md`](test/integration/README.md) — Gherkin step reference, available tags, writing new tests.
- [`.bruno/README.md`](.bruno/README.md) — `.bru` file format, conventions, route update checklist.
- [`pkg/modules/pdfengines/README.md`](pkg/modules/pdfengines/README.md) — adding new engine features (Makefile variable and flag).

View File

@@ -18,6 +18,8 @@ GOTENBERG_BUILD_DEBUG_DATA=true
API_PORT=3000
API_PORT_FROM_ENV=
API_BIND_IP=
API_TLS_CERT_FILE=
API_TLS_KEY_FILE=
API_START_TIMEOUT=30s
API_TIMEOUT=30s
API_BODY_LIMIT=
@@ -27,7 +29,10 @@ API_ENABLE_BASIC_AUTH=false
GOTENBERG_API_BASIC_AUTH_USERNAME=
GOTENBERG_API_BASIC_AUTH_PASSWORD=
API_DOWNLOAD_FROM_ALLOW_LIST=
API_DOWNLOAD_FROM_DENY_LIST=
API_DOWNLOAD_FROM_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)
API_DOWNLOAD_FROM_DENY_PRIVATE_IPS=false
API_DOWNLOAD_FROM_DENY_PUBLIC_IPS=false
API_DOWNLOAD_FROM_ENABLE_ENVIRONMENT_PROXY=false
API_DOWNLOAD_FROM_MAX_RETRY=4
API_DISABLE_DOWNLOAD_FROM=false
API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=true
@@ -47,8 +52,11 @@ CHROMIUM_DISABLE_WEB_SECURITY=false
CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false
CHROMIUM_HOST_RESOLVER_RULES=
CHROMIUM_PROXY_SERVER=
CHROMIUM_ENABLE_ENVIRONMENT_PROXY=false
CHROMIUM_ALLOW_LIST=
CHROMIUM_DENY_LIST=^file:(?!//\/tmp/).*
CHROMIUM_DENY_PRIVATE_IPS=false
CHROMIUM_DENY_PUBLIC_IPS=false
CHROMIUM_CLEAR_CACHE=false
CHROMIUM_CLEAR_COOKIES=false
CHROMIUM_DISABLE_JAVASCRIPT=false
@@ -58,11 +66,17 @@ LIBREOFFICE_MAX_QUEUE_SIZE=0
LIBREOFFICE_IDLE_SHUTDOWN_TIMEOUT=0
LIBREOFFICE_AUTO_START=false
LIBREOFFICE_START_TIMEOUT=20s
LIBREOFFICE_ALLOW_LIST=
LIBREOFFICE_DENY_LIST=
LIBREOFFICE_DENY_PRIVATE_IPS=false
LIBREOFFICE_DENY_PUBLIC_IPS=false
LIBREOFFICE_ENABLE_ENVIRONMENT_PROXY=false
LIBREOFFICE_DISABLE_ROUTES=false
LOG_LEVEL=info
LOG_FIELDS_PREFIX=
LOG_STD_FORMAT=auto
LOG_STD_ENABLE_GCP_FIELDS=false
LOG_STD_LEVEL_CASE=lower
PDFENGINES_DISABLE_ROUTES=false
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
@@ -76,7 +90,9 @@ PDFENGINES_WATERMARK_ENGINES=pdfcpu,pdftk
PDFENGINES_STAMP_ENGINES=pdfcpu,pdftk
PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
PDFENGINES_EMBED_ENGINES=pdfcpu
PDFENGINES_EMBED_ENGINES=qpdf,pdfcpu
PDFENGINES_EMBED_METADATA_ENGINES=qpdf
PDFENGINES_FACTUR_X_ENGINES=qpdf
PROMETHEUS_NAMESPACE=gotenberg
PROMETHEUS_COLLECT_INTERVAL=1s
PROMETHEUS_DISABLE_ROUTE_TELEMETRY=true
@@ -91,9 +107,10 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
OTEL_EXPORTER_OTLP_INSECURE=true
WEBHOOK_ENABLE_SYNC_MODE=false
WEBHOOK_ALLOW_LIST=
WEBHOOK_DENY_LIST=
WEBHOOK_ERROR_ALLOW_LIST=
WEBHOOK_ERROR_DENY_LIST=
WEBHOOK_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)
WEBHOOK_DENY_PRIVATE_IPS=false
WEBHOOK_DENY_PUBLIC_IPS=false
WEBHOOK_ENABLE_ENVIRONMENT_PROXY=false
WEBHOOK_MAX_RETRY=4
WEBHOOK_RETRY_MIN_WAIT=1s
WEBHOOK_RETRY_MAX_WAIT=30s
@@ -155,6 +172,7 @@ NO_CONCURRENCY=false
# stamp
# pdfengines-rotate
# rotate
# factur-x
# pdfengines-bookmarks
# bookmarks
# prometheus-metrics

View File

@@ -1,5 +1,5 @@
<p align="center">
<img src="https://user-images.githubusercontent.com/8983173/130322857-185831e2-f041-46eb-a17f-0a69d066c4e5.png" alt="Gotenberg Logo" width="150" height="150" />
<img src="https://raw.githubusercontent.com/gotenberg/art/master/logo.png" alt="Gotenberg Logo" width="150" height="150" />
<h3 align="center">Gotenberg</h3>
<p align="center">A Docker-based API for converting documents to PDF</p>
<p align="center">
@@ -64,6 +64,7 @@ If Gotenberg powers your workflow or your business, consider [**becoming a spons
- [TheCodingMachine](https://thecodingmachine.com/)
- [pdfme](https://pdfme.com/)
- [PDFBolt](https://pdfbolt.com)
- [FileToPDF.dev](https://filetopdf.dev)
**Powered By**

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.26.0
ARG GOLANG_VERSION=1.26.5
# ----------------------------------------------
# pdfcpu binary build stage
@@ -11,7 +11,7 @@ ARG GOLANG_VERSION=1.26.0
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
# See https://github.com/pdfcpu/pdfcpu/releases.
ARG PDFCPU_VERSION=v0.11.1
ARG PDFCPU_VERSION=v0.13.0
ENV CGO_ENABLED=0
# Define the working directory outside of $GOPATH (we're using go modules).
@@ -24,7 +24,7 @@ RUN curl -Ls "https://github.com/pdfcpu/pdfcpu/archive/refs/tags/$PDFCPU_VERSION
RUN go mod download \
&& go mod verify
RUN go build -o pdfcpu -ldflags "-s -w -X 'main.version=$PDFCPU_VERSION' -X 'github.com/pdfcpu/pdfcpu/pkg/pdfcpu.VersionStr=$PDFCPU_VERSION' -X main.builtBy=gotenberg" ./cmd/pdfcpu \
RUN go build -o pdfcpu -ldflags "-s -w -X 'main.version=$PDFCPU_VERSION' -X 'github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model.VersionStr=$PDFCPU_VERSION' -X main.builtBy=gotenberg" ./cmd/pdfcpu \
# Verify installation.
&& ./pdfcpu version
@@ -88,7 +88,7 @@ RUN apt-get update -qq \
WORKDIR /downloads
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.2.0/unoconv -o unoconverter \
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.4.0/unoconv -o unoconverter \
&& chmod +x unoconverter
RUN curl -o pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" \
@@ -125,7 +125,10 @@ RUN groupadd --gid "$GOTENBERG_USER_GID" gotenberg \
RUN apt-get update -qq \
&& apt-get upgrade -yqq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
ca-certificates \
tini \
# Many users rely on curl for Docker health checks.
curl \
# Install fonts.
# Essential metric-compatible fonts for LibreOffice layout fidelity (replaces MS Fonts).
fonts-crosextra-carlito \
@@ -187,6 +190,19 @@ ENV QPDF_BIN_PATH=/usr/bin/qpdf
ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool
ENV PDFCPU_BIN_PATH=/usr/bin/pdfcpu
# Capture backing-binary versions at build time so the running process reports
# them on traces without spawning the binaries at startup or per request.
# See pkg/gotenberg/buildversions.go. Chromium and LibreOffice are captured in
# the variant stages below, where they are installed.
ENV GOTENBERG_VERSIONS_DIR_PATH=/opt/gotenberg/versions
COPY --link build/capture-version.sh /opt/gotenberg/capture-version.sh
RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" qpdf "$QPDF_BIN_PATH" --version \
&& bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" exiftool "$EXIFTOOL_BIN_PATH" -ver \
&& bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" pdftk "$PDFTK_BIN_PATH" --version \
&& bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" pdfcpu "$PDFCPU_BIN_PATH" version
# OpenTelemetry defaults (noop - no telemetry overhead unless explicitly enabled).
ENV OTEL_TRACES_EXPORTER=none
ENV OTEL_METRICS_EXPORTER=none
@@ -200,6 +216,7 @@ FROM common-stage AS gotenberg
ARG GOTENBERG_VERSION=snapshot
ARG GOTENBERG_USER_GID=1001
ARG GOTENBERG_USER_UID=1001
ARG TMP_CHROMIUM_VERSION_PPC64EL="146.0.7680.80-1~deb13u1"
LABEL org.opencontainers.image.title="Gotenberg" \
org.opencontainers.image.description="A Docker-based API for converting documents to PDF." \
@@ -209,10 +226,23 @@ LABEL org.opencontainers.image.title="Gotenberg" \
org.opencontainers.image.source="https://github.com/gotenberg/gotenberg"
# Install Chromium.
RUN apt-get update -qq \
&& apt-get upgrade -yqq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium \
# Cleanup.
# On ppc64el, the latest Chromium is broken, so we pin a known working
# version from snapshot.debian.org via debsnap.
RUN /bin/bash -c \
'set -e &&\
if [[ "$(dpkg --print-architecture)" == "ppc64el" ]]; then \
apt-get update -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends devscripts &&\
debsnap chromium-common "$TMP_CHROMIUM_VERSION_PPC64EL" -v --force --binary --architecture ppc64el &&\
debsnap chromium "$TMP_CHROMIUM_VERSION_PPC64EL" -v --force --binary --architecture ppc64el &&\
DEBIAN_FRONTEND=noninteractive apt-get install --fix-broken -y -qq --no-install-recommends "./binary-chromium-common/chromium-common_${TMP_CHROMIUM_VERSION_PPC64EL}_ppc64el.deb" "./binary-chromium/chromium_${TMP_CHROMIUM_VERSION_PPC64EL}_ppc64el.deb" &&\
DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq devscripts &&\
rm -rf ./binary-chromium-common/* ./binary-chromium/*; \
else \
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \
fi' \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Install LibreOffice & unoconverter.
@@ -252,6 +282,10 @@ ENV CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
# Capture Chromium and LibreOffice versions now that both are installed.
RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" chromium "$CHROMIUM_BIN_PATH" --version \
&& bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" libreoffice-api "$LIBREOFFICE_BIN_PATH" --version
USER gotenberg
WORKDIR /home/gotenberg
@@ -269,6 +303,7 @@ FROM common-stage AS gotenberg-chromium
ARG GOTENBERG_VERSION=snapshot
ARG GOTENBERG_USER_GID=1001
ARG GOTENBERG_USER_UID=1001
ARG TMP_CHROMIUM_VERSION_PPC64EL="146.0.7680.80-1~deb13u1"
LABEL org.opencontainers.image.title="Gotenberg (Chromium)" \
org.opencontainers.image.description="A Docker-based API for converting documents to PDF — Chromium variant." \
@@ -278,10 +313,23 @@ LABEL org.opencontainers.image.title="Gotenberg (Chromium)" \
org.opencontainers.image.source="https://github.com/gotenberg/gotenberg"
# Install Chromium.
RUN apt-get update -qq \
&& apt-get upgrade -yqq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium \
# Cleanup.
# On ppc64el, the latest Chromium is broken, so we pin a known working
# version from snapshot.debian.org via debsnap.
RUN /bin/bash -c \
'set -e &&\
if [[ "$(dpkg --print-architecture)" == "ppc64el" ]]; then \
apt-get update -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends devscripts &&\
debsnap chromium-common "$TMP_CHROMIUM_VERSION_PPC64EL" -v --force --binary --architecture ppc64el &&\
debsnap chromium "$TMP_CHROMIUM_VERSION_PPC64EL" -v --force --binary --architecture ppc64el &&\
DEBIAN_FRONTEND=noninteractive apt-get install --fix-broken -y -qq --no-install-recommends "./binary-chromium-common/chromium-common_${TMP_CHROMIUM_VERSION_PPC64EL}_ppc64el.deb" "./binary-chromium/chromium_${TMP_CHROMIUM_VERSION_PPC64EL}_ppc64el.deb" &&\
DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq devscripts &&\
rm -rf ./binary-chromium-common/* ./binary-chromium/*; \
else \
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \
fi' \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# COPY instructions last to maximize cache reuse.
@@ -295,6 +343,11 @@ COPY --link --chown="$GOTENBERG_USER_UID:$GOTENBERG_USER_GID" build/chromium-hyp
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
ENV CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data
# No LibreOffice in this variant; override the default to use all available engines.
ENV PDFENGINES_CONVERT_ENGINES=
# Capture the Chromium version now that it is installed.
RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" chromium "$CHROMIUM_BIN_PATH" --version
USER gotenberg
WORKDIR /home/gotenberg
@@ -350,6 +403,9 @@ COPY --link --from=downloader-stage /downloads/unoconverter /usr/bin/unoconverte
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
# Capture the LibreOffice version now that it is installed.
RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" libreoffice-api "$LIBREOFFICE_BIN_PATH" --version
USER gotenberg
WORKDIR /home/gotenberg

34
build/capture-version.sh Normal file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Captures the version of a backing binary into a per-module file that the
# running Gotenberg process reads via gotenberg.BuildVersion, so it never spawns
# the binary just to report a version. This keeps cold start and the first
# request cheap, which matters on serverless platforms.
#
# Failure-tolerant by design: a probe that errors writes an empty file, and the
# runtime falls back to detecting the version live. A failing probe must never
# fail the image build.
#
# Usage: capture-version.sh <output-dir> <module-id> <bin> [args...]
set -u
dir="$1"
id="$2"
shift 2
mkdir -p "$dir"
# Run the probe once. On failure, keep going with empty output.
raw="$("$@" 2>/dev/null)" || raw=""
case "$id" in
pdfcpu)
# pdfcpu prints "pdfcpu: <version>"; keep only the part the runtime parser
# keeps so the recorded value matches the live-detection fallback.
version="$(printf '%s\n' "$raw" | grep -m1 '^pdfcpu:' | sed 's/^pdfcpu:[[:space:]]*//')"
;;
*)
version="$(printf '%s\n' "$raw" | head -n1)"
;;
esac
printf '%s' "$version" | tr -d '\r' >"$dir/$id"

View File

@@ -49,6 +49,7 @@ func Run() {
fs.String("log-fields-prefix", "", "Prepend a specified prefix to each log field key")
fs.String("log-std-format", gotenberg.AutoLoggingFormat, "Set the log format for standard output")
fs.Bool("log-std-enable-gcp-fields", false, "Use GCP-compatible field names in log output")
fs.String("log-std-level-case", gotenberg.LowerLevelCase, "Set the case of the level field in the standard output, either lower or upper")
// Deprecated logging flags.
fs.String("log-format", gotenberg.AutoLoggingFormat, "Set the log format")
@@ -123,6 +124,7 @@ func Run() {
LogFieldsPrefix: parsedFlags.MustString("log-fields-prefix"),
LogStdFormat: parsedFlags.MustDeprecatedString("log-format", "log-std-format"),
LogStdEnableGcpFields: parsedFlags.MustDeprecatedBool("log-enable-gcp-fields", "log-std-enable-gcp-fields"),
LogStdLevelCase: parsedFlags.MustString("log-std-level-case"),
}
// LogLevel uses its own flag, not the format flag.
telemetryCfg.LogLevel = parsedFlags.MustString("log-level")

View File

@@ -21,6 +21,8 @@ services:
- "--api-port=${API_PORT}"
- "--api-port-from-env=${API_PORT_FROM_ENV}"
- "--api-bind-ip=${API_BIND_IP}"
- "--api-tls-cert-file=${API_TLS_CERT_FILE}"
- "--api-tls-key-file=${API_TLS_KEY_FILE}"
- "--api-start-timeout=${API_START_TIMEOUT}"
- "--api-timeout=${API_TIMEOUT}"
- "--api-body-limit=${API_BODY_LIMIT}"
@@ -29,6 +31,9 @@ services:
- "--api-enable-basic-auth=${API_ENABLE_BASIC_AUTH}"
- "--api-download-from-allow-list=${API_DOWNLOAD_FROM_ALLOW_LIST}"
- "--api-download-from-deny-list=${API_DOWNLOAD_FROM_DENY_LIST}"
- "--api-download-from-deny-private-ips=${API_DOWNLOAD_FROM_DENY_PRIVATE_IPS}"
- "--api-download-from-deny-public-ips=${API_DOWNLOAD_FROM_DENY_PUBLIC_IPS}"
- "--api-download-from-enable-environment-proxy=${API_DOWNLOAD_FROM_ENABLE_ENVIRONMENT_PROXY}"
- "--api-download-from-max-retry=${API_DOWNLOAD_FROM_MAX_RETRY}"
- "--api-disable-download-from=${API_DISABLE_DOWNLOAD_FROM}"
- "--api-disable-health-check-route-telemetry=${API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY}"
@@ -48,8 +53,11 @@ services:
- "--chromium-allow-file-access-from-files=${CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES}"
- "--chromium-host-resolver-rules=${CHROMIUM_HOST_RESOLVER_RULES}"
- "--chromium-proxy-server=${CHROMIUM_PROXY_SERVER}"
- "--chromium-enable-environment-proxy=${CHROMIUM_ENABLE_ENVIRONMENT_PROXY}"
- "--chromium-allow-list=${CHROMIUM_ALLOW_LIST}"
- "--chromium-deny-list=${CHROMIUM_DENY_LIST}"
- "--chromium-deny-private-ips=${CHROMIUM_DENY_PRIVATE_IPS}"
- "--chromium-deny-public-ips=${CHROMIUM_DENY_PUBLIC_IPS}"
- "--chromium-clear-cache=${CHROMIUM_CLEAR_CACHE}"
- "--chromium-clear-cookies=${CHROMIUM_CLEAR_COOKIES}"
- "--chromium-disable-javascript=${CHROMIUM_DISABLE_JAVASCRIPT}"
@@ -59,11 +67,17 @@ services:
- "--libreoffice-idle-shutdown-timeout=${LIBREOFFICE_IDLE_SHUTDOWN_TIMEOUT}"
- "--libreoffice-auto-start=${LIBREOFFICE_AUTO_START}"
- "--libreoffice-start-timeout=${LIBREOFFICE_START_TIMEOUT}"
- "--libreoffice-allow-list=${LIBREOFFICE_ALLOW_LIST}"
- "--libreoffice-deny-list=${LIBREOFFICE_DENY_LIST}"
- "--libreoffice-deny-private-ips=${LIBREOFFICE_DENY_PRIVATE_IPS}"
- "--libreoffice-deny-public-ips=${LIBREOFFICE_DENY_PUBLIC_IPS}"
- "--libreoffice-enable-environment-proxy=${LIBREOFFICE_ENABLE_ENVIRONMENT_PROXY}"
- "--libreoffice-disable-routes=${LIBREOFFICE_DISABLE_ROUTES}"
- "--log-level=${LOG_LEVEL}"
- "--log-fields-prefix=${LOG_FIELDS_PREFIX}"
- "--log-std-format=${LOG_STD_FORMAT}"
- "--log-std-enable-gcp-fields=${LOG_STD_ENABLE_GCP_FIELDS}"
- "--log-std-level-case=${LOG_STD_LEVEL_CASE}"
- "--pdfengines-merge-engines=${PDFENGINES_MERGE_ENGINES}"
- "--pdfengines-split-engines=${PDFENGINES_SPLIT_ENGINES}"
- "--pdfengines-flatten-engines=${PDFENGINES_FLATTEN_ENGINES}"
@@ -77,6 +91,8 @@ services:
- "--pdfengines-encrypt-engines=${PDFENGINES_ENCRYPT_ENGINES}"
- "--pdfengines-rotate-engines=${PDFENGINES_ROTATE_ENGINES}"
- "--pdfengines-embed-engines=${PDFENGINES_EMBED_ENGINES}"
- "--pdfengines-embed-metadata-engines=${PDFENGINES_EMBED_METADATA_ENGINES}"
- "--pdfengines-factur-x-engines=${PDFENGINES_FACTUR_X_ENGINES}"
- "--pdfengines-disable-routes=${PDFENGINES_DISABLE_ROUTES}"
- "--prometheus-namespace=${PROMETHEUS_NAMESPACE}"
- "--prometheus-collect-interval=${PROMETHEUS_COLLECT_INTERVAL}"
@@ -86,8 +102,9 @@ services:
- "--webhook-enable-sync-mode=${WEBHOOK_ENABLE_SYNC_MODE}"
- "--webhook-allow-list=${WEBHOOK_ALLOW_LIST}"
- "--webhook-deny-list=${WEBHOOK_DENY_LIST}"
- "--webhook-error-allow-list=${WEBHOOK_ERROR_ALLOW_LIST}"
- "--webhook-error-deny-list=${WEBHOOK_ERROR_DENY_LIST}"
- "--webhook-deny-private-ips=${WEBHOOK_DENY_PRIVATE_IPS}"
- "--webhook-deny-public-ips=${WEBHOOK_DENY_PUBLIC_IPS}"
- "--webhook-enable-environment-proxy=${WEBHOOK_ENABLE_ENVIRONMENT_PROXY}"
- "--webhook-max-retry=${WEBHOOK_MAX_RETRY}"
- "--webhook-retry-min-wait=${WEBHOOK_RETRY_MIN_WAIT}"
- "--webhook-retry-max-wait=${WEBHOOK_RETRY_MAX_WAIT}"

137
go.mod
View File

@@ -1,41 +1,40 @@
module github.com/gotenberg/gotenberg/v8
go 1.26.0
go 1.26.5
require (
github.com/alexliesenfeld/health v0.8.1
github.com/barasher/go-exiftool v1.10.0
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc
github.com/chromedp/chromedp v0.15.1
github.com/cucumber/godog v0.15.1
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-20260217112301-37c66b85d6ab
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d // pinned with chromedp v0.14.2, see below
github.com/chromedp/chromedp v0.14.2 // pinned: v0.15.x breaks the headless print-mode paint pipeline (rAF / ResizeObserver / IntersectionObserver stop firing, blank charts). See https://github.com/gotenberg/gotenberg/issues/1535.
github.com/cucumber/godog v0.16.0
github.com/dlclark/regexp2 v1.12.0
github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76
github.com/google/uuid v1.6.0
github.com/hashicorp/go-retryablehttp v0.7.8
github.com/labstack/echo/v4 v4.15.1
github.com/labstack/gommon v0.4.2
github.com/labstack/echo/v4 v4.15.4
github.com/labstack/gommon v0.5.0
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.26.2
github.com/moby/moby/api v1.55.0
github.com/moby/moby/client v0.5.1
github.com/prometheus/client_golang v1.24.1
github.com/shirou/gopsutil/v4 v4.26.7
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.41.0
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0
go.opentelemetry.io/otel v1.42.0
go.opentelemetry.io/otel/log v0.18.0
go.opentelemetry.io/otel/metric v1.42.0
go.opentelemetry.io/otel/sdk v1.42.0
go.opentelemetry.io/otel/sdk/log v0.18.0
go.opentelemetry.io/otel/sdk/metric v1.42.0
go.opentelemetry.io/otel/trace v1.42.0
golang.org/x/net v0.52.0
golang.org/x/sync v0.20.0
golang.org/x/term v0.41.0
golang.org/x/text v0.35.0
github.com/testcontainers/testcontainers-go v0.43.0
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0
go.opentelemetry.io/otel v1.45.0
go.opentelemetry.io/otel/log v0.20.0
go.opentelemetry.io/otel/metric v1.45.0
go.opentelemetry.io/otel/sdk v1.45.0
go.opentelemetry.io/otel/sdk/log v0.20.0
go.opentelemetry.io/otel/sdk/metric v1.45.0
go.opentelemetry.io/otel/trace v1.45.0
golang.org/x/net v0.57.0
golang.org/x/sync v0.22.0
golang.org/x/term v0.45.0
golang.org/x/text v0.40.0
)
require (
@@ -43,11 +42,11 @@ require (
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/STARRY-S/zip v0.2.3 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bodgit/plumbing v1.3.0 // indirect
github.com/bodgit/sevenzip v1.6.1 // indirect
github.com/bodgit/sevenzip v1.6.4 // indirect
github.com/bodgit/windows v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
@@ -58,89 +57,87 @@ require (
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect
github.com/cucumber/messages/go/v21 v21.0.1 // indirect
github.com/cucumber/gherkin/go/v42 v42.0.0 // indirect
github.com/cucumber/messages/go/v34 v34.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/ebitengine/purego v0.10.2 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/gofrs/uuid v4.4.0+incompatible // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
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.5 // indirect
github.com/klauspost/compress v1.19.1 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // 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/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mikelolasagasti/xz v1.0.1 // indirect
github.com/minio/minlz v1.1.0 // indirect
github.com/minio/minlz v1.1.1 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/sequential v0.7.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.1.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nwaples/rardecode/v2 v2.2.2 // indirect
github.com/nwaples/rardecode/v2 v2.2.5 // 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.26 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pierrec/lz4/v4 v4.1.27 // 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.5 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/prometheus/procfs v0.21.1 // 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/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/stangelandcl/ppmd v0.1.1 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
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/bridges/prometheus v0.67.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.66.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 // indirect
google.golang.org/grpc v1.82.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

306
go.sum
View File

@@ -10,18 +10,16 @@ github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
github.com/alexliesenfeld/health v0.8.1 h1:wdE3vt+cbJotiR8DGDBZPKHDFoJbAoWEfQTcqrmedUg=
github.com/alexliesenfeld/health v0.8.1/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEXNMpIhs=
github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4=
github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8=
github.com/bodgit/sevenzip v1.6.4 h1:iHiVJfxbrB6RF4X+snI2MpVgNBKmVfGaTqZGNlMQIU0=
github.com/bodgit/sevenzip v1.6.4/go.mod h1:ZtNi5KNgHXeXg1G7WiF0IWSuFE2eG6lt/cTGlvuirO0=
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=
@@ -30,10 +28,10 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
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-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4=
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU=
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
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/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
@@ -46,43 +44,39 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI=
github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0=
github.com/cucumber/godog v0.15.1 h1:rb/6oHDdvVZKS66hrhpjFQFHjthFSrQBCOI1LwshNTI=
github.com/cucumber/godog v0.15.1/go.mod h1:qju+SQDewOljHuq9NSM66s0xEhogx0q30flfxL4WUk8=
github.com/cucumber/messages/go/v21 v21.0.1 h1:wzA0LxwjlWQYZd32VTlAVDTkW6inOFmSM+RuOwHZiMI=
github.com/cucumber/messages/go/v21 v21.0.1/go.mod h1:zheH/2HS9JLVFukdrsPWoPdmUtmYQAQPLk7w5vWsk5s=
github.com/cucumber/messages/go/v22 v22.0.0/go.mod h1:aZipXTKc0JnjCsXrJnuZpWhtay93k7Rn3Dee7iyPJjs=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/cucumber/gherkin/go/v42 v42.0.0 h1:Ulh3E2awUUSSja+wonP/IOQ+ycmiZwZbgmzqk5H8JNI=
github.com/cucumber/gherkin/go/v42 v42.0.0/go.mod h1:CsaumaO2dR9XvBc6ZyiGLMhWCKtTRDxgoxqJigSjSSg=
github.com/cucumber/godog v0.16.0 h1:ezQbgItuWqZrjPUQwLJ3muwIlvzXBOfZso5QZfG7efE=
github.com/cucumber/godog v0.16.0/go.mod h1:EDUX9yCqANK+GpbftMDeu61sUDtdLuo1JJgXD2n3bbM=
github.com/cucumber/messages/go/v34 v34.2.0 h1:VCbcNOMz+f8ccjjOOx1NLBNhwvE7/X49Atc8klJa+i8=
github.com/cucumber/messages/go/v34 v34.2.0/go.mod h1:LYUPjqlTS1kS0pdkdf6sS5uirnjwiIzEGyXPezXNhL8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
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/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6 h1:nxP4pPoyqOAgX8lYDFCfl3DyKeXErCvSvhcyzwGV9CE=
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6/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=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
@@ -94,14 +88,10 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
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/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76 h1:Ltt9ldIaSYEsjA7sPY2c8r9dOmnKM1vlzhh3dxlhBHM=
github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76/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=
@@ -109,16 +99,14 @@ 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/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.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-memdb v1.3.4/go.mod h1:uBTr1oQbtuMgd1SSGoR8YV27eT3sBHbYiNm53bMpgSg=
github.com/hashicorp/go-memdb v1.3.5 h1:b3taDMxCBCBVgyRrS1AZVHO14ubMYZB++QpNhBg+Nyo=
github.com/hashicorp/go-memdb v1.3.5/go.mod h1:8IVKKBkVe+fxFgdFOYxzQQNjz+sWCyHCdIC/+5+Vy1Y=
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
@@ -132,124 +120,114 @@ github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iP
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/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
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.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=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
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.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs=
github.com/labstack/echo/v4 v4.15.1/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/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs=
github.com/labstack/echo/v4 v4.15.4/go.mod h1:CuMetKIRwsuO/qlAgMq+KTAalwGoB/h4tC+yPdrTj1g=
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
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-20260324052639-156f7da3f749 h1:Qj3hTcdWH8uMZDI41HNuTuJN525C7NBrbtH5kSO6fPk=
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/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=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0=
github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
github.com/minio/minlz v1.1.0 h1:rUOGu3EP4EqJC5k3qCsIwEnZiJULKqtRyDdqbhlvMmQ=
github.com/minio/minlz v1.1.0/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
github.com/minio/minlz v1.1.1 h1:OGmft1V6AnI/Wme332U6bhG54nxEan+VFgkD7lat4KM=
github.com/minio/minlz v1.1.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.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw=
github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
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.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.2 h1:/5oL8dzYivRM/tqX9VcTSWfbpwcbwKG1QtSJr3b3KcU=
github.com/nwaples/rardecode/v2 v2.2.2/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
github.com/nwaples/rardecode/v2 v2.2.5 h1:L5doqgGfQwI7qADJMqnkrSB86rpPsqQDrHeO0HWa5JY=
github.com/nwaples/rardecode/v2 v2.2.5/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.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/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/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
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.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
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/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI=
github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
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=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stangelandcl/ppmd v0.1.1 h1:c25QazhlWUn5nmR1QOzafKhQxBicAr7GGCKER2aJ8H8=
github.com/stangelandcl/ppmd v0.1.1/go.mod h1:Rrv7M+/2P5jYr/GMLhBl7Ug3uJ1bUiVzr5LbbaV6xgY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
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/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
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=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.41.0 h1:mfpsD0D36YgkxGj2LrIyxuwQ9i2wCKAD+ESsYM1wais=
github.com/testcontainers/testcontainers-go v0.41.0/go.mod h1:pdFrEIfaPl24zmBjerWTTYaY0M6UHsqA1YSvsoU40MI=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/testcontainers/testcontainers-go v0.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A=
github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo=
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
@@ -263,52 +241,54 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0 h1:NFIS6x7wyObQ7cR84x7bt1sr8nYBx89s3x3GwRjw40k=
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0/go.mod h1:39SaByOyDMRMe872AE7uelMuQZidIw7LLFAnQi0FWTE=
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 h1:dkBzNEAIKADEaFnuESzcXvpd09vxvDZsOjx11gjUqLk=
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0/go.mod h1:Z5RIwRkZgauOIfnG5IpidvLpERjhTninpP1dTG2jTl4=
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 h1:4fnRcNpc6YFtG3zsFw9achKn3XgmxPxuMuqIL5rE8e8=
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0/go.mod h1:qTvIHMFKoxW7HXg02gm6/Wofhq5p3Ib/A/NNt1EoBSQ=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 h1:deI9UQMoGFgrg5iLPgzueqFPHevDl+28YKfSpPTI6rY=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0/go.mod h1:PFx9NgpNUKXdf7J4Q3agRxMs3Y07QhTCVipKmLsMKnU=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 h1:icqq3Z34UrEFk2u+HMhTtRsvo7Ues+eiJVjaJt62njs=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0/go.mod h1:W2m8P+d5Wn5kipj4/xmbt9uMqezEKfBjzVJadfABSBE=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 h1:H7O6RlGOMTizyl3R08Kn5pdM06bnH8oscSj7o11tmLA=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0/go.mod h1:mBFWu/WOVDkWWsR7Tx7h6EpQB8wsv7P0Yrh0Pb7othc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc=
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs=
go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 h1:KJVjPD3rcPb98rIs3HznyJlrfx9ge5oJvxxlGR+P/7s=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0/go.mod h1:K3kRa2ckmHWQaTWQdPRHc7qGXASuVuoEQXzrvlA98Ws=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs=
go.opentelemetry.io/otel/log v0.18.0 h1:XgeQIIBjZZrliksMEbcwMZefoOSMI1hdjiLEiiB0bAg=
go.opentelemetry.io/otel/log v0.18.0/go.mod h1:KEV1kad0NofR3ycsiDH4Yjcoj0+8206I6Ox2QYFSNgI=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
go.opentelemetry.io/otel/sdk/log v0.18.0 h1:n8OyZr7t7otkeTnPTbDNom6rW16TBYGtvyy2Gk6buQw=
go.opentelemetry.io/otel/sdk/log v0.18.0/go.mod h1:C0+wxkTwKpOCZLrlJ3pewPiiQwpzycPI/u6W0Z9fuYk=
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0 h1:l3mYuPsuBx6UKE47BVcPrZoZ0q/KER57vbj2qkgDLXA=
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0/go.mod h1:7cHtiVJpZebB3wybTa4NG+FUo5NPe3PROz1FqB0+qdw=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 h1:5RgvxieNq9tS3ewrV1vnODvbHPfKUIJcYtF9Cvz+6aQ=
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0/go.mod h1:iTBIdNwx/xmUhfgJs6+84S4dIK059811cO1eUBjKcHY=
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0 h1:saQoWg5845Q8TojpqeVStS7zGwVZ6bc5W2PJavTPiBM=
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0/go.mod h1:AAaS6xs5AyqMdR3Ir0nSWK+QudL2XM8Vbw5INzUxNc8=
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0 h1:R3jsCoTIzv0BiYNhW0axyswn/6SMJ8xL1OuGxvni1Kw=
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0/go.mod h1:m07gqyr2QhQxKOKb5vqKCCBtLH3uqlNYR7PU/FISXVU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0/go.mod h1:MP4eemTiI9zC8fgg+DYynhYDYf3ba72S376TvP+Ye0Q=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
go.opentelemetry.io/otel/exporters/prometheus v0.66.0 h1:vkrK8PAznv2NKt2r+kdu252ccGzkEqLc2aSXbQIALYQ=
go.opentelemetry.io/otel/exporters/prometheus v0.66.0/go.mod h1:V/UB6D3vMF/UBOL5igAsAYnk1nG/bzYYTzvsB16cy7o=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 h1:aZfdmtI6QU/DAPD4b7YZ5zuJgewxO1EW9miOZklqleU=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0/go.mod h1:isNl10/Om5CBWu9jj8WOb2+tJLbCVXDgqwzCaJMnJ6w=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0=
go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs=
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E=
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns=
go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI=
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY=
go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU=
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg=
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M=
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -317,34 +297,34 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 h1:g0RAkxK/smSu/iRwC/KIX1mwUoVJtk2OjbgaeS4DmUM=
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 h1:9HZDLIdYBJXAnaFOr9WHrKVycfpY+75s9HGadC0305A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
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=
@@ -355,3 +335,5 @@ 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=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=

107
package-lock.json generated
View File

@@ -5,9 +5,9 @@
"packages": {
"": {
"devDependencies": {
"prettier": "3.8.1",
"prettier": "3.9.6",
"prettier-plugin-gherkin": "^3.1.3",
"prettier-plugin-sh": "^0.18.0"
"prettier-plugin-sh": "^0.19.0"
}
},
"node_modules/@cucumber/gherkin": {
@@ -34,15 +34,80 @@
}
},
"node_modules/@reteps/dockerfmt": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.3.6.tgz",
"integrity": "sha512-Tb5wIMvBf/nLejTQ61krK644/CEMB/cpiaIFXqGApfGqO3GwcR3qnI0DbmkFVCl2OyEp8LnLX3EkucoL0+tbFg==",
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.5.4.tgz",
"integrity": "sha512-HEGgXVVOb+JtGUSSzXl/XPKFIZjMDTUoHarCjaQdkY+cb5M9K/O3b5xm+x0IPIk3SfHurbc0bSgcFsQlzjitxA==",
"dev": true,
"license": "MIT",
"bin": {
"dockerfmt": "dist/launcher.js"
},
"engines": {
"node": "^v12.20.0 || ^14.13.0 || >=16.0.0"
},
"optionalDependencies": {
"@reteps/dockerfmt-darwin-arm64": "0.5.4",
"@reteps/dockerfmt-darwin-x64": "0.5.4",
"@reteps/dockerfmt-linux-arm64": "0.5.4",
"@reteps/dockerfmt-linux-x64": "0.5.4"
}
},
"node_modules/@reteps/dockerfmt-darwin-arm64": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt-darwin-arm64/-/dockerfmt-darwin-arm64-0.5.4.tgz",
"integrity": "sha512-urMqV+dQyvVI8/WrXwClX9e1PEyS35wFdwJjpZYmL09AkV4Io5U1oam8UBKK7jZk0+YsdF88ay6e86Kn6DIyQg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@reteps/dockerfmt-darwin-x64": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt-darwin-x64/-/dockerfmt-darwin-x64-0.5.4.tgz",
"integrity": "sha512-fJORy6DFxbgDiMqxpLTPZlb5KUY0Vq0iR4NGnyKnuYZ9LdZUS508DK2kt/AJ87/jIKNV1qRG0JXG1Tc6xdvWjw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@reteps/dockerfmt-linux-arm64": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt-linux-arm64/-/dockerfmt-linux-arm64-0.5.4.tgz",
"integrity": "sha512-6pVakO06eXtDuvxy1Dnjs/gQyUoGGycle8PRSt5IFRwLi/AVaOQwfkfmW0WP8VH9wNUeti6BfJ3ksTn2G+XMxg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@reteps/dockerfmt-linux-x64": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt-linux-x64/-/dockerfmt-linux-x64-0.5.4.tgz",
"integrity": "sha512-OD6SIlUV1D4TgJoTui3FMBAZsGbTSPYsiT0BKhD6jMUcJb3GpFTa7dY9rL8rP9FUqfL7OTHVUGUOL4Rh64Olog==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
@@ -58,9 +123,9 @@
"license": "MIT"
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"version": "3.9.6",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
"dev": true,
"license": "MIT",
"bin": {
@@ -86,14 +151,14 @@
}
},
"node_modules/prettier-plugin-sh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.18.0.tgz",
"integrity": "sha512-cW1XL27FOJQ/qGHOW6IHwdCiNWQsAgK+feA8V6+xUTaH0cD3Mh+tFAtBvEEWvuY6hTDzRV943Fzeii+qMOh7nQ==",
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.19.0.tgz",
"integrity": "sha512-39VXFZH/cOGtcuu8aeSvqp/hhwomOR4QroZUj+jBz2cNb3os9s0sqFZSNlYts6jdtLLDU7D2YT3Z1+abtb7adQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@reteps/dockerfmt": "^0.3.6",
"sh-syntax": "^0.5.8"
"@reteps/dockerfmt": "^0.5.4",
"sh-syntax": "^0.6.0"
},
"engines": {
"node": ">=16.0.0"
@@ -113,14 +178,11 @@
"license": "Apache-2.0"
},
"node_modules/sh-syntax": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.5.8.tgz",
"integrity": "sha512-JfVoxf4FxQI5qpsPbkHhZo+n6N9YMJobyl4oGEUBb/31oQYlgTjkXQD8PBiafS2UbWoxrTO0Z5PJUBXEPAG1Zw==",
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.6.0.tgz",
"integrity": "sha512-52VK6z/cdZHv7UURjIcwfBUQZrAhIEEe0bY4lrkfypjnFIKsDZdD3Uaz/dBiw/sF8BeX0Mssv140s8EnrsJ9dQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
},
"engines": {
"node": ">=16.0.0"
},
@@ -128,13 +190,6 @@
"url": "https://opencollective.com/sh-syntax"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/uuid": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz",

View File

@@ -1,7 +1,7 @@
{
"devDependencies": {
"prettier": "3.8.1",
"prettier": "3.9.6",
"prettier-plugin-gherkin": "^3.1.3",
"prettier-plugin-sh": "^0.18.0"
"prettier-plugin-sh": "^0.19.0"
}
}

53
pkg/gotenberg/attrs.go Normal file
View File

@@ -0,0 +1,53 @@
package gotenberg
import (
"net/url"
"slices"
)
// maxAttrRunes bounds the length of a string span attribute to keep payload
// size and backend cardinality in check.
const maxAttrRunes = 256
// CapAttr truncates s to at most [maxAttrRunes] runes, appending an ellipsis
// when it shortens the value. It is multibyte-safe.
func CapAttr(s string) string {
runes := []rune(s)
if len(runes) <= maxAttrRunes {
return s
}
return string(runes[:maxAttrRunes-1]) + "…"
}
// RedactURL parses raw and returns a redacted, length-capped form safe to use
// as a span attribute or event value. Userinfo, query, and fragment are
// dropped because they may carry credentials or other sensitive data. It
// returns an empty string when raw is empty or cannot be parsed.
func RedactURL(raw string) string {
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err != nil {
return ""
}
parsed.User = nil
parsed.RawQuery = ""
parsed.ForceQuery = false
parsed.Fragment = ""
parsed.RawFragment = ""
return CapAttr(parsed.String())
}
// MapEnum returns value when it belongs to allowed, otherwise "other". It keeps
// a span attribute or metric dimension bounded even when an upstream tool
// introduces a new enum value.
func MapEnum(value string, allowed ...string) string {
if slices.Contains(allowed, value) {
return value
}
return "other"
}

View File

@@ -0,0 +1,62 @@
package gotenberg
import (
"strings"
"testing"
)
func TestRedactURL(t *testing.T) {
for _, tc := range []struct {
name string
raw string
want string
}{
{"empty", "", ""},
{"strips userinfo query fragment", "https://user:pass@example.com/path?token=secret#frag", "https://example.com/path"},
{"keeps host and path", "http://example.com/a/b", "http://example.com/a/b"},
{"parse error", "http://example.com/%zz", ""},
} {
t.Run(tc.name, func(t *testing.T) {
if got := RedactURL(tc.raw); got != tc.want {
t.Errorf("RedactURL(%q) = %q, want %q", tc.raw, got, tc.want)
}
})
}
}
func TestRedactURL_Caps(t *testing.T) {
raw := "https://example.com/" + strings.Repeat("a", 400)
got := RedactURL(raw)
if n := len([]rune(got)); n != maxAttrRunes {
t.Errorf("expected capped length %d, got %d", maxAttrRunes, n)
}
}
func TestCapAttr(t *testing.T) {
t.Run("short unchanged", func(t *testing.T) {
if got := CapAttr("short"); got != "short" {
t.Errorf("expected unchanged, got %q", got)
}
})
t.Run("multibyte truncated to rune cap", func(t *testing.T) {
got := CapAttr(strings.Repeat("é", 400))
if n := len([]rune(got)); n != maxAttrRunes {
t.Errorf("expected %d runes, got %d", maxAttrRunes, n)
}
if !strings.HasSuffix(got, "…") {
t.Error("expected an ellipsis suffix on a truncated value")
}
})
}
func TestMapEnum(t *testing.T) {
allowed := []string{"document", "stylesheet", "script"}
if got := MapEnum("script", allowed...); got != "script" {
t.Errorf("expected member passthrough, got %q", got)
}
if got := MapEnum("websocket", allowed...); got != "other" {
t.Errorf("expected non-member to map to other, got %q", got)
}
}

View File

@@ -0,0 +1,50 @@
package gotenberg
import (
"os"
"path/filepath"
"strings"
)
// BuildVersionsDirPathEnvVar names the environment variable holding the
// absolute path to a directory of build-time version files. The Gotenberg image
// writes one file per module there, named by module ID and holding the version
// string of that module's backing binary, captured right after the binary is
// installed. The running process reads these files instead of executing the
// binaries, which keeps startup and the first request cheap.
const BuildVersionsDirPathEnvVar = "GOTENBERG_VERSIONS_DIR_PATH"
// BuildVersion returns the build-time version captured for the module with the
// given ID. The boolean is false when no version was captured, which is the
// case for local or non-Docker builds where the directory is absent. A module
// uses it to avoid spawning its backing binary just to report a version.
//
// It is defensive: an unset variable, a missing or unreadable file, or an empty
// value all yield ("", false), so the caller falls back to detecting the
// version at runtime. See [BuildVersionsDirPathEnvVar].
func BuildVersion(moduleID string) (string, bool) {
dir := os.Getenv(BuildVersionsDirPathEnvVar)
if dir == "" {
return "", false
}
// Module IDs are fixed internal constants, never paths. Guard anyway so a
// stray separator can't escape the versions directory.
if moduleID != filepath.Base(moduleID) {
return "", false
}
// The directory comes from a trusted operator-set environment variable,
// mirroring how engines exec their env-configured binaries.
b, err := os.ReadFile(filepath.Join(dir, moduleID)) //nolint:gosec
if err != nil {
return "", false
}
version := strings.TrimSpace(string(b))
if version == "" {
return "", false
}
return version, true
}

View File

@@ -0,0 +1,81 @@
package gotenberg
import (
"os"
"path/filepath"
"testing"
)
func TestBuildVersion(t *testing.T) {
for _, tc := range []struct {
scenario string
fileBody string
writeFile bool
setEnv bool
moduleID string
wantValue string
wantOk bool
}{
{
scenario: "version present",
fileBody: "Chromium 146.0",
writeFile: true,
setEnv: true,
moduleID: "chromium",
wantValue: "Chromium 146.0",
wantOk: true,
},
{
scenario: "value trimmed",
fileBody: " qpdf version 11.9.0 \n",
writeFile: true,
setEnv: true,
moduleID: "qpdf",
wantValue: "qpdf version 11.9.0",
wantOk: true,
},
{
scenario: "empty file falls back",
fileBody: " \n",
writeFile: true,
setEnv: true,
moduleID: "pdftk",
wantValue: "",
wantOk: false,
},
{
scenario: "missing file falls back",
writeFile: false,
setEnv: true,
moduleID: "exiftool",
wantValue: "",
wantOk: false,
},
{
scenario: "env unset falls back",
setEnv: false,
moduleID: "chromium",
wantValue: "",
wantOk: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
if tc.setEnv {
dir := t.TempDir()
if tc.writeFile {
if err := os.WriteFile(filepath.Join(dir, tc.moduleID), []byte(tc.fileBody), 0o600); err != nil {
t.Fatalf("write version file: %v", err)
}
}
t.Setenv(BuildVersionsDirPathEnvVar, dir)
} else {
t.Setenv(BuildVersionsDirPathEnvVar, "")
}
value, ok := BuildVersion(tc.moduleID)
if value != tc.wantValue || ok != tc.wantOk {
t.Errorf("BuildVersion(%q) = (%q, %t), want (%q, %t)", tc.moduleID, value, ok, tc.wantValue, tc.wantOk)
}
})
}
}

View File

@@ -8,8 +8,14 @@ import (
"io"
"log/slog"
"os/exec"
"path/filepath"
"strings"
"syscall"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
"go.opentelemetry.io/otel/trace"
)
// Cmd wraps an [exec.Cmd].
@@ -55,6 +61,13 @@ func CommandContext(ctx context.Context, logger *slog.Logger, binPath string, ar
}, nil
}
// SetEnv replaces the environment variables passed to the underlying
// process. When SetEnv is not called, the process inherits the parent's
// environment.
func (cmd *Cmd) SetEnv(env []string) {
cmd.process.Env = env
}
// Start starts the command but does not wait for its completion.
func (cmd *Cmd) Start() error {
err := cmd.pipeOutput()
@@ -85,11 +98,44 @@ func (cmd *Cmd) Wait() error {
// Exec executes the command and waits for its completion or until the context
// is done. In any case, it kills the unix process and all its children.
//
// When the context carries an active trace span, Exec records a
// "process.exec" client span around the execution. It is the single
// instrumentation point for every short-lived external binary (soffice, pdftk,
// qpdf, exiftool, pdfcpu). The span is skipped when there is no active parent,
// so process starts performed off the request path do not emit orphan roots.
func (cmd *Cmd) Exec() (int, error) {
if cmd.ctx == nil {
return 10, errors.New("nil context")
}
var span trace.Span
if trace.SpanContextFromContext(cmd.ctx).IsValid() {
_, span = Tracer().Start(cmd.ctx, "process.exec",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ProcessExecutableName(filepath.Base(cmd.process.Path))),
)
defer span.End()
}
code, err := cmd.exec()
if span != nil {
span.SetAttributes(attribute.Int("process.exit.code", code))
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
span.SetAttributes(semconv.ErrorTypeKey.String(execErrorType(cmd.ctx)))
} else {
span.SetStatus(codes.Ok, "")
}
}
return code, err
}
// exec runs the command and returns its exit code and error.
func (cmd *Cmd) exec() (int, error) {
err := cmd.Start()
if err != nil {
if cmd.process.ProcessState == nil {
@@ -131,6 +177,19 @@ func (cmd *Cmd) Exec() (int, error) {
}
}
// execErrorType maps an execution failure to a bounded semconv error.type
// value.
func execErrorType(ctx context.Context) string {
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
return "context_deadline_exceeded"
case errors.Is(ctx.Err(), context.Canceled):
return "context_canceled"
default:
return "process_error"
}
}
// pipeOutput creates logs entries according to the process stdout and stderr.
// It does nothing if the logging level is not debug.
func (cmd *Cmd) pipeOutput() error {

136
pkg/gotenberg/cmd_test.go Normal file
View File

@@ -0,0 +1,136 @@
package gotenberg
import (
"context"
"log/slog"
"testing"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
)
func newTestSpanRecorder(t *testing.T) *tracetest.SpanRecorder {
t.Helper()
recorder := tracetest.NewSpanRecorder()
provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
previous := otel.GetTracerProvider()
otel.SetTracerProvider(provider)
t.Cleanup(func() { otel.SetTracerProvider(previous) })
return recorder
}
func findSpan(recorder *tracetest.SpanRecorder, name string) sdktrace.ReadOnlySpan {
for _, s := range recorder.Ended() {
if s.Name() == name {
return s
}
}
return nil
}
func spanAttr(span sdktrace.ReadOnlySpan, key string) (attribute.Value, bool) {
for _, kv := range span.Attributes() {
if string(kv.Key) == key {
return kv.Value, true
}
}
return attribute.Value{}, false
}
func TestCmd_Exec_NilContext(t *testing.T) {
cmd := Command(slog.New(slog.DiscardHandler), "true")
code, err := cmd.Exec()
if err == nil {
t.Error("expected an error for a nil context")
}
if code != 10 {
t.Errorf("expected code 10, got %d", code)
}
}
func TestCmd_Exec_NoParentSpanProducesNoSpan(t *testing.T) {
recorder := newTestSpanRecorder(t)
cmd, err := CommandContext(context.Background(), slog.New(slog.DiscardHandler), "sh", "-c", "exit 0")
if err != nil {
t.Fatalf("create command: %v", err)
}
code, err := cmd.Exec()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if code != 0 {
t.Errorf("expected code 0, got %d", code)
}
if n := len(recorder.Ended()); n != 0 {
t.Errorf("expected no span without an active parent, got %d", n)
}
}
func TestCmd_Exec_RecordsSpanOnSuccess(t *testing.T) {
recorder := newTestSpanRecorder(t)
ctx, parent := otel.Tracer("test").Start(context.Background(), "parent")
cmd, err := CommandContext(ctx, slog.New(slog.DiscardHandler), "sh", "-c", "exit 0")
if err != nil {
t.Fatalf("create command: %v", err)
}
code, err := cmd.Exec()
parent.End()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if code != 0 {
t.Errorf("expected code 0, got %d", code)
}
span := findSpan(recorder, "process.exec")
if span == nil {
t.Fatal("expected a process.exec span to be recorded")
}
if span.Status().Code != codes.Ok {
t.Errorf("expected status Ok, got %v", span.Status().Code)
}
if name, ok := spanAttr(span, "process.executable.name"); !ok || name.AsString() != "sh" {
t.Errorf("expected process.executable.name=sh, got %q (present=%t)", name.AsString(), ok)
}
if exit, ok := spanAttr(span, "process.exit.code"); !ok || exit.AsInt64() != 0 {
t.Errorf("expected process.exit.code=0, got %d (present=%t)", exit.AsInt64(), ok)
}
}
func TestCmd_Exec_RecordsSpanOnError(t *testing.T) {
recorder := newTestSpanRecorder(t)
ctx, parent := otel.Tracer("test").Start(context.Background(), "parent")
cmd, err := CommandContext(ctx, slog.New(slog.DiscardHandler), "sh", "-c", "exit 3")
if err != nil {
t.Fatalf("create command: %v", err)
}
code, err := cmd.Exec()
parent.End()
if err == nil {
t.Error("expected an error for a non-zero exit code")
}
if code != 3 {
t.Errorf("expected exit code 3, got %d", code)
}
span := findSpan(recorder, "process.exec")
if span == nil {
t.Fatal("expected a process.exec span to be recorded")
}
if span.Status().Code != codes.Error {
t.Errorf("expected status Error, got %v", span.Status().Code)
}
if et, ok := spanAttr(span, "error.type"); !ok || et.AsString() != "process_error" {
t.Errorf("expected error.type=process_error, got %q (present=%t)", et.AsString(), ok)
}
}

View File

@@ -0,0 +1,52 @@
package gotenberg
import (
"context"
"errors"
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
"go.opentelemetry.io/otel/trace"
)
// Engine-agnostic, low-cardinality error.type values shared by the conversion
// engines. They are safe to use both as the semconv error.type span attribute
// and as bounded metric label values.
const (
ErrorTypeTimeout = "timeout"
ErrorTypeContextCancelled = "context_cancelled"
ErrorTypeQueueSizeExceeded = "queue_size_exceeded"
ErrorTypeProcessRestarting = "process_restarting"
ErrorTypeInvalidInput = "invalid_input"
ErrorTypeUnknown = "unknown"
)
// ClassifyError maps err to a bounded, engine-agnostic error.type value. It
// recognizes the failure modes shared by every engine: deadline, cancellation,
// queue saturation, and process restart. It returns an empty string for a nil
// error and [ErrorTypeUnknown] for anything it does not recognize, leaving
// engine-specific refinement (such as [ErrorTypeInvalidInput]) to the caller.
func ClassifyError(err error) string {
switch {
case err == nil:
return ""
case errors.Is(err, context.DeadlineExceeded):
return ErrorTypeTimeout
case errors.Is(err, context.Canceled):
return ErrorTypeContextCancelled
case errors.Is(err, ErrMaximumQueueSizeExceeded):
return ErrorTypeQueueSizeExceeded
case errors.Is(err, ErrProcessAlreadyRestarting):
return ErrorTypeProcessRestarting
default:
return ErrorTypeUnknown
}
}
// SpanErrorType records errorType as the semconv error.type attribute on span.
// It is a no-op when errorType is empty.
func SpanErrorType(span trace.Span, errorType string) {
if errorType == "" {
return
}
span.SetAttributes(semconv.ErrorTypeKey.String(errorType))
}

View File

@@ -0,0 +1,60 @@
package gotenberg
import (
"context"
"errors"
"fmt"
"testing"
"go.opentelemetry.io/otel"
)
func TestClassifyError(t *testing.T) {
for _, tc := range []struct {
name string
err error
want string
}{
{"nil", nil, ""},
{"deadline", context.DeadlineExceeded, ErrorTypeTimeout},
{"canceled", context.Canceled, ErrorTypeContextCancelled},
{"queue size exceeded", ErrMaximumQueueSizeExceeded, ErrorTypeQueueSizeExceeded},
{"process restarting", ErrProcessAlreadyRestarting, ErrorTypeProcessRestarting},
{"wrapped deadline", fmt.Errorf("convert: %w", context.DeadlineExceeded), ErrorTypeTimeout},
{"joined queue", errors.Join(errors.New("attempt"), ErrMaximumQueueSizeExceeded), ErrorTypeQueueSizeExceeded},
{"arbitrary", errors.New("boom"), ErrorTypeUnknown},
} {
t.Run(tc.name, func(t *testing.T) {
if got := ClassifyError(tc.err); got != tc.want {
t.Errorf("ClassifyError(%v) = %q, want %q", tc.err, got, tc.want)
}
})
}
}
func TestSpanErrorType(t *testing.T) {
recorder := newTestSpanRecorder(t)
_, span := otel.Tracer("test").Start(context.Background(), "engine.Op")
SpanErrorType(span, "") // no-op, must not add an attribute
SpanErrorType(span, ErrorTypeTimeout) // sets error.type
span.End()
got := findSpan(recorder, "engine.Op")
if got == nil {
t.Fatal("expected the span to be recorded")
}
count := 0
for _, kv := range got.Attributes() {
if string(kv.Key) == "error.type" {
count++
if kv.Value.AsString() != ErrorTypeTimeout {
t.Errorf("expected error.type=%q, got %q", ErrorTypeTimeout, kv.Value.AsString())
}
}
}
if count != 1 {
t.Errorf("expected exactly one error.type attribute, got %d", count)
}
}

View File

@@ -50,7 +50,12 @@ func (h traceContextHandler) WithGroup(name string) slog.Handler {
}
// NewStdHandler returns a [slog.Handler] instance for the standard output.
func NewStdHandler(level slog.Level, format string, fieldsPrefix string, enableGcpFields bool) (slog.Handler, error) {
// upperLevelCase is the value of the level-case setting that keeps the level
// field uppercase in the standard output. It mirrors gotenberg.UpperLevelCase,
// duplicated here because the internal log package cannot import gotenberg.
const upperLevelCase = "upper"
func NewStdHandler(level slog.Level, format string, fieldsPrefix string, enableGcpFields bool, levelCase string) (slog.Handler, error) {
// #nosec: G115
isTerminal := term.IsTerminal(int(os.Stdout.Fd()))
@@ -82,7 +87,11 @@ func NewStdHandler(level slog.Level, format string, fieldsPrefix string, enableG
a.Key = "severity"
a.Value = slog.StringValue(gcpSeverity(l))
default:
a.Value = slog.StringValue(strings.ToLower(l.String()))
if levelCase == upperLevelCase {
a.Value = slog.StringValue(l.String())
} else {
a.Value = slog.StringValue(strings.ToLower(l.String()))
}
}
}

View File

@@ -0,0 +1,53 @@
package log
import (
"encoding/json"
"io"
"log/slog"
"os"
"testing"
)
func TestNewStdHandler_LevelCase(t *testing.T) {
for _, tc := range []struct {
name string
levelCase string
want string
}{
{"lower is the default behavior", "lower", "info"},
{"upper keeps slog casing", "upper", "INFO"},
} {
t.Run(tc.name, func(t *testing.T) {
original := os.Stderr
reader, writer, err := os.Pipe()
if err != nil {
t.Fatalf("create pipe: %v", err)
}
os.Stderr = writer
defer func() { os.Stderr = original }()
handler, err := NewStdHandler(slog.LevelInfo, "json", "", false, tc.levelCase)
if err != nil {
t.Fatalf("create handler: %v", err)
}
slog.New(handler).Info("hello")
if err := writer.Close(); err != nil {
t.Fatalf("close writer: %v", err)
}
out, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("read output: %v", err)
}
var record map[string]any
if err := json.Unmarshal(out, &record); err != nil {
t.Fatalf("parse log line %q: %v", out, err)
}
if record["level"] != tc.want {
t.Errorf("level = %v, want %v", record["level"], tc.want)
}
})
}
}

View File

@@ -3,5 +3,22 @@
//
// Significantly inspired by https://github.com/lucavallin/gotel.
//
// # Sampling
//
// No sampler is configured in code, so the SDK default applies:
// parentbased_always_on. Every trace is recorded and exported. Operators tune
// sampling through the standard environment variables, honored by the SDK:
//
// OTEL_TRACES_SAMPLER e.g. parentbased_traceidratio, always_off
// OTEL_TRACES_SAMPLER_ARG e.g. 0.1 for a 10% ratio
//
// Head sampling drops whole traces up front, including the rare slow or failed
// conversions that matter most for diagnosis. For high-throughput deployments,
// prefer keeping head sampling permissive and applying tail sampling in the
// collector (sample on error or high latency), which decides after a trace
// completes. Gotenberg emits trace-based metric exemplars, so the conversion
// histograms still link to representative traces regardless of the head
// sampling ratio.
//
// See https://opentelemetry.io/.
package otel

View File

@@ -14,33 +14,73 @@ import (
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/log"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/exemplar"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
semconv "go.opentelemetry.io/otel/semconv/v1.43.0"
)
// buildResource assembles the OpenTelemetry resource shared by the tracer,
// meter, and logger providers. Detection is best-effort: a detector or merge
// failure is logged and the build proceeds with whatever was gathered, so a
// flaky environment never prevents telemetry from starting.
//
// The semconv version imported here must match the one the SDK resource
// detectors use (go.opentelemetry.io/otel/sdk/resource). Drift makes
// [resource.Merge] fail with [resource.ErrSchemaURLConflict] and strips the
// schema URL off every exported signal.
func buildResource(ctx context.Context, logger *slog.Logger, serviceName, serviceVersion string) *resource.Resource {
base := resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName(serviceName),
semconv.ServiceVersion(serviceVersion),
)
// Granular detectors only. The WithProcess() bundle is deliberately omitted
// because it adds process.command_args/process.command_line, which can carry
// proxy credentials and host-resolver rules passed on the command line.
detected, err := resource.New(ctx,
resource.WithFromEnv(),
resource.WithTelemetrySDK(),
resource.WithHost(),
resource.WithHostID(),
resource.WithOS(),
resource.WithContainer(),
resource.WithProcessPID(),
resource.WithProcessExecutableName(),
resource.WithProcessExecutablePath(),
resource.WithProcessRuntimeName(),
resource.WithProcessRuntimeVersion(),
resource.WithProcessRuntimeDescription(),
)
if err != nil {
logger.WarnContext(ctx, fmt.Sprintf("partially detect OpenTelemetry resource: %s", err))
}
if detected == nil {
return base
}
// A schema URL conflict still yields a resource holding every attribute, only
// without a schema URL. Keep it: falling back to base would drop the host,
// OS, container, process, and OTEL_RESOURCE_ATTRIBUTES data.
merged, err := resource.Merge(detected, base)
if err != nil {
logger.WarnContext(ctx, fmt.Sprintf("merge OpenTelemetry resource: %s", err))
}
if merged == nil {
return base
}
return merged
}
// InitTracerProvider initializes the OpenTelemetry tracer provider.
func InitTracerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, err error) {
initOtelLogger(logger)
ctx := context.Background()
hostname, err := os.Hostname()
if err != nil {
return nil, fmt.Errorf("get hostname: %w", err)
}
res, err := resource.Merge(
resource.Default(),
resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName(serviceName),
semconv.ServiceVersion(serviceVersion),
semconv.HostName(hostname),
),
)
if err != nil {
return nil, fmt.Errorf("merge resource: %w", err)
}
res := buildResource(ctx, logger, serviceName, serviceVersion)
traceOpts := []trace.TracerProviderOption{
trace.WithResource(res),
@@ -71,27 +111,13 @@ func InitMeterProvider(logger *slog.Logger, serviceName, serviceVersion string)
initOtelLogger(logger)
ctx := context.Background()
hostname, err := os.Hostname()
if err != nil {
return nil, fmt.Errorf("get hostname: %w", err)
}
res, err := resource.Merge(
resource.Default(),
resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName(serviceName),
semconv.ServiceVersion(serviceVersion),
semconv.HostName(hostname),
),
)
if err != nil {
return nil, fmt.Errorf("merge resource: %w", err)
}
res := buildResource(ctx, logger, serviceName, serviceVersion)
metricOpts := []metric.Option{
metric.WithResource(res),
}
metricOpts = append(metricOpts, exemplarFilterOptions()...)
metricReader, err := autoexport.NewMetricReader(ctx)
if err != nil {
@@ -108,28 +134,24 @@ func InitMeterProvider(logger *slog.Logger, serviceName, serviceVersion string)
return meterProvider.Shutdown, nil
}
// exemplarFilterOptions returns the meter provider options that pin trace-based
// exemplars, so the histograms expose the trace id of a representative
// measurement. It yields no option when the operator selects a filter via
// OTEL_METRICS_EXEMPLAR_FILTER, letting the SDK's own env handling win.
func exemplarFilterOptions() []metric.Option {
if _, ok := os.LookupEnv("OTEL_METRICS_EXEMPLAR_FILTER"); ok {
return nil
}
return []metric.Option{metric.WithExemplarFilter(exemplar.TraceBasedFilter)}
}
// InitLoggerProvider initializes the OpenTelemetry logger provider.
func InitLoggerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, handler slog.Handler, err error) {
initOtelLogger(logger)
ctx := context.Background()
hostname, err := os.Hostname()
if err != nil {
return nil, nil, fmt.Errorf("get hostname: %w", err)
}
res, err := resource.Merge(
resource.Default(),
resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName(serviceName),
semconv.ServiceVersion(serviceVersion),
semconv.HostName(hostname),
),
)
if err != nil {
return nil, nil, fmt.Errorf("merge resource: %w", err)
}
res := buildResource(ctx, logger, serviceName, serviceVersion)
logOpts := []log.LoggerProviderOption{
log.WithResource(res),

View File

@@ -0,0 +1,158 @@
package otel
import (
"context"
"log/slog"
"os"
"testing"
"go.opentelemetry.io/otel"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/exemplar"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.43.0"
)
func TestBuildResource(t *testing.T) {
res := buildResource(context.Background(), slog.New(slog.DiscardHandler), "gotenberg", "v8.0.0")
got := map[string]struct{}{}
values := map[string]string{}
for _, kv := range res.Attributes() {
got[string(kv.Key)] = struct{}{}
values[string(kv.Key)] = kv.Value.AsString()
}
// Guards the semconv version pinned in buildResource against the one the SDK
// resource detectors use. Drift makes resource.Merge conflict and drops the
// schema URL from every exported signal.
if res.SchemaURL() != semconv.SchemaURL {
t.Errorf("resource schema URL = %q, want %q", res.SchemaURL(), semconv.SchemaURL)
}
if values[string(semconv.ServiceNameKey)] != "gotenberg" {
t.Errorf("service.name = %q, want %q", values[string(semconv.ServiceNameKey)], "gotenberg")
}
if values[string(semconv.ServiceVersionKey)] != "v8.0.0" {
t.Errorf("service.version = %q, want %q", values[string(semconv.ServiceVersionKey)], "v8.0.0")
}
for _, key := range []string{
string(semconv.HostNameKey),
string(semconv.OSTypeKey),
string(semconv.ProcessRuntimeNameKey),
} {
if _, ok := got[key]; !ok {
t.Errorf("expected resource attribute %q to be present", key)
}
}
// The command-line bundle must never be detected (it can leak credentials).
for _, key := range []string{"process.command_args", "process.command_line"} {
if _, ok := got[key]; ok {
t.Errorf("did not expect sensitive resource attribute %q", key)
}
}
}
// TestInitTracerProvider_HonorsSamplerEnv guards the contract that the tracer
// provider keeps honoring OTEL_TRACES_SAMPLER. The SDK reads it only when no
// explicit sampler is configured, so any future WithSampler() would silently
// break operator-side sampling control.
func TestInitTracerProvider_HonorsSamplerEnv(t *testing.T) {
for _, tc := range []struct {
name string
sampler string
wantSampled bool
}{
{"always off", "always_off", false},
{"always on", "always_on", true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("OTEL_TRACES_EXPORTER", "none")
t.Setenv("OTEL_TRACES_SAMPLER", tc.sampler)
shutdown, err := InitTracerProvider(slog.New(slog.DiscardHandler), "test", "v0.0.0")
if err != nil {
t.Fatalf("init tracer provider: %v", err)
}
t.Cleanup(func() { _ = shutdown(context.Background()) })
_, span := otel.Tracer("test").Start(context.Background(), "span")
span.End()
if got := span.SpanContext().IsSampled(); got != tc.wantSampled {
t.Errorf("OTEL_TRACES_SAMPLER=%q: IsSampled() = %v, want %v", tc.sampler, got, tc.wantSampled)
}
})
}
}
func TestExemplarFilterOptions(t *testing.T) {
t.Run("default pins trace-based", func(t *testing.T) {
if v, ok := os.LookupEnv("OTEL_METRICS_EXEMPLAR_FILTER"); ok {
os.Unsetenv("OTEL_METRICS_EXEMPLAR_FILTER")
t.Cleanup(func() { os.Setenv("OTEL_METRICS_EXEMPLAR_FILTER", v) })
}
if got := exemplarFilterOptions(); len(got) != 1 {
t.Errorf("expected 1 option when env unset, got %d", len(got))
}
})
t.Run("env override yields no option", func(t *testing.T) {
t.Setenv("OTEL_METRICS_EXEMPLAR_FILTER", "always_off")
if got := exemplarFilterOptions(); len(got) != 0 {
t.Errorf("expected 0 options when env set, got %d", len(got))
}
})
}
// TestMeterProvider_TraceBasedExemplar guards that the trace-based filter we pin
// actually attaches a trace id to a histogram measurement recorded inside a
// sampled span.
func TestMeterProvider_TraceBasedExemplar(t *testing.T) {
reader := sdkmetric.NewManualReader()
provider := sdkmetric.NewMeterProvider(
sdkmetric.WithReader(reader),
sdkmetric.WithExemplarFilter(exemplar.TraceBasedFilter),
)
t.Cleanup(func() { _ = provider.Shutdown(context.Background()) })
hist, err := provider.Meter("test").Float64Histogram("conversion.duration")
if err != nil {
t.Fatalf("create histogram: %v", err)
}
tracer := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.AlwaysSample())).Tracer("test")
ctx, span := tracer.Start(context.Background(), "conversion")
hist.Record(ctx, 1.0)
traceID := span.SpanContext().TraceID()
span.End()
var rm metricdata.ResourceMetrics
if err := reader.Collect(context.Background(), &rm); err != nil {
t.Fatalf("collect: %v", err)
}
var found bool
for _, sm := range rm.ScopeMetrics {
for _, m := range sm.Metrics {
hd, ok := m.Data.(metricdata.Histogram[float64])
if !ok {
continue
}
for _, dp := range hd.DataPoints {
for _, ex := range dp.Exemplars {
if string(ex.TraceID) == string(traceID[:]) {
found = true
}
}
}
}
}
if !found {
t.Error("expected a trace-based exemplar carrying the span trace id")
}
}

View File

@@ -45,20 +45,23 @@ func (mod *DebuggableMock) Debug() map[string]any {
//
//nolint:dupl
type PdfEngineMock struct {
MergeMock func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath string) error
ConvertMock func(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
ReadBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error)
EncryptMock func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
EmbedFilesMock func(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
WriteBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
WatermarkMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
StampMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
RotateMock func(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
MergeMock func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath string) error
ConvertMock func(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
ReadBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error)
EncryptMock func(ctx context.Context, logger *slog.Logger, inputPath string, opts EncryptOptions) error
EmbedFilesMock func(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
EmbedFilesMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error
WriteBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
WatermarkMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
StampMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
RotateMock func(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
InjectFacturXXMPMock func(ctx context.Context, logger *slog.Logger, facturX FacturX, inputPath string) error
ReadPdfAConformanceMock func(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error)
}
func (engine *PdfEngineMock) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
@@ -93,14 +96,18 @@ func (engine *PdfEngineMock) ReadBookmarks(ctx context.Context, logger *slog.Log
return engine.ReadBookmarksMock(ctx, logger, inputPath)
}
func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
return engine.EncryptMock(ctx, logger, inputPath, userPassword, ownerPassword)
func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts EncryptOptions) error {
return engine.EncryptMock(ctx, logger, inputPath, opts)
}
func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
return engine.EmbedFilesMock(ctx, logger, filePaths, inputPath)
}
func (engine *PdfEngineMock) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
return engine.EmbedFilesMetadataMock(ctx, logger, metadata, inputPath)
}
func (engine *PdfEngineMock) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error {
return engine.WriteBookmarksMock(ctx, logger, inputPath, bookmarks)
}
@@ -117,6 +124,14 @@ func (engine *PdfEngineMock) Rotate(ctx context.Context, logger *slog.Logger, in
return engine.RotateMock(ctx, logger, inputPath, angle, pages)
}
func (engine *PdfEngineMock) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX FacturX, inputPath string) error {
return engine.InjectFacturXXMPMock(ctx, logger, facturX, inputPath)
}
func (engine *PdfEngineMock) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
return engine.ReadPdfAConformanceMock(ctx, logger, inputPath)
}
// PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface.
type PdfEngineProviderMock struct {
PdfEngineMock func() (PdfEngine, error)
@@ -147,13 +162,14 @@ func (p *ProcessMock) Healthy(logger *slog.Logger) bool {
// ProcessSupervisorMock is a mock for the [ProcessSupervisor] interface.
type ProcessSupervisorMock struct {
LaunchMock func() error
ShutdownMock func() error
HealthyMock func() bool
RunMock func(ctx context.Context, logger *slog.Logger, task func() error) error
ReqQueueSizeMock func() int64
RestartsCountMock func() int64
ActiveTasksCountMock func() int64
LaunchMock func() error
ShutdownMock func() error
HealthyMock func() bool
RunMock func(ctx context.Context, logger *slog.Logger, task func() error) error
ReqQueueSizeMock func() int64
RestartsCountMock func() int64
ActiveTasksCountMock func() int64
ConversionsSinceRestartMock func() int64
}
func (s *ProcessSupervisorMock) Launch() error {
@@ -184,6 +200,10 @@ func (s *ProcessSupervisorMock) ActiveTasksCount() int64 {
return s.ActiveTasksCountMock()
}
func (s *ProcessSupervisorMock) ConversionsSinceRestart() int64 {
return s.ConversionsSinceRestartMock()
}
// MetricsProviderMock is a mock for the [MetricsProvider] interface.
type MetricsProviderMock struct {
MetricsMock func() ([]Metric, error)

676
pkg/gotenberg/outbound.go Normal file
View File

@@ -0,0 +1,676 @@
package gotenberg
import (
"bufio"
"context"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"strings"
"time"
"github.com/dlclark/regexp2"
"golang.org/x/net/http/httpproxy"
)
// ErrNonPublicIP indicates that an outbound URL targets an IP address that
// is not reachable on the public internet. This covers loopback, RFC1918
// private, link-local, unspecified, multicast, and IPv6 unique-local
// (fc00::/7) addresses, as well as their IPv4-mapped IPv6 wrappers (for
// example [::ffff:127.0.0.1]).
var ErrNonPublicIP = errors.New("non-public IP")
// ErrPublicIP indicates that an outbound URL targets an IP address that is
// reachable on the public internet. It is returned when a caller opts
// into denying public destinations via [WithDenyPublicIPs]; typical use
// cases are air-gapped or data-governed deployments where Gotenberg must
// only talk to hosts on a private network.
var ErrPublicIP = errors.New("public IP")
// netipResolver is the subset of [net.Resolver] used by [resolveHost].
// Defining it as an interface allows tests to substitute a stub resolver.
type netipResolver interface {
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
}
// outboundResolver is the resolver used by [resolveHost]. It is a
// package-level variable so that tests can substitute a stub resolver.
var outboundResolver netipResolver = net.DefaultResolver
// outboundDialer is the underlying dialer used by [secureDialContext]. It is
// a package-level variable so that tests can replace it.
var outboundDialer = &net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}
// nonPublicIPv6Prefixes lists IPv6 ranges that the standard library does
// not classify via [netip.Addr] helpers but that must not be considered
// public:
//
// - 2002::/16 6to4 (RFC 3056, deprecated by RFC 7526). Bits 16-47
// embed an IPv4 destination, including private ones.
// - 2001::/32 Teredo (RFC 4380). Bits 96-127 embed an IPv4
// destination, including private ones.
// - 64:ff9b::/96 NAT64 well-known prefix (RFC 6052). Low 32 bits
// embed an IPv4 destination translated by a NAT64 gateway.
// - 64:ff9b:1::/48 NAT64 local-use prefix (RFC 8215). Same risk.
// - fec0::/10 Deprecated site-local (RFC 3879). Not covered by
// [netip.Addr.IsPrivate] which only handles fc00::/7.
// - ::/96 IPv4-compatible IPv6 (deprecated). Embeds an IPv4
// destination and is not handled by [netip.Addr.Unmap].
// - 2001:db8::/32 Documentation range (RFC 3849). Never routable.
// - 100::/64 Discard prefix (RFC 6666).
var nonPublicIPv6Prefixes = []netip.Prefix{
netip.MustParsePrefix("2002::/16"),
netip.MustParsePrefix("2001::/32"),
netip.MustParsePrefix("64:ff9b::/96"),
netip.MustParsePrefix("64:ff9b:1::/48"),
netip.MustParsePrefix("fec0::/10"),
netip.MustParsePrefix("::/96"),
netip.MustParsePrefix("2001:db8::/32"),
netip.MustParsePrefix("100::/64"),
}
// IsPublicIP reports whether addr is reachable on the public internet. It
// returns false for loopback, private (RFC1918), link-local, unspecified,
// multicast, and unique-local addresses. IPv4-mapped IPv6 addresses are
// unmapped before evaluation so that [::ffff:127.0.0.1] is correctly
// identified as loopback.
//
// IPv6 prefixes that tunnel or translate to an embedded IPv4 destination
// (6to4, Teredo, NAT64) are rejected wholesale rather than recursed into,
// because a host that routes them implicitly trusts the IPv4 mapping and
// the prefixes themselves are deprecated or translation-only. See
// [nonPublicIPv6Prefixes] for the full list and rationale.
func IsPublicIP(addr netip.Addr) bool {
if !addr.IsValid() {
return false
}
addr = addr.Unmap()
switch {
case addr.IsLoopback(),
addr.IsPrivate(),
addr.IsLinkLocalUnicast(),
addr.IsLinkLocalMulticast(),
addr.IsMulticast(),
addr.IsUnspecified(),
addr.IsInterfaceLocalMulticast():
return false
}
if addr.Is6() {
for _, p := range nonPublicIPv6Prefixes {
if p.Contains(addr) {
return false
}
}
}
return true
}
// ResolveAndCheckPublic resolves host and rejects any resolved address
// that fails [IsPublicIP] with [ErrNonPublicIP]. It is the strict
// equivalent of [DecideOutbound] with [WithDenyPrivateIPs] true for a
// bare host. Callers that need a different policy should use
// [DecideOutbound] directly.
func ResolveAndCheckPublic(ctx context.Context, host string) ([]netip.Addr, error) {
return resolveHost(ctx, host, true, false)
}
// resolveHost resolves host and returns the addresses. When denyPrivate
// is true, a non-public address is rejected with [ErrNonPublicIP]. When
// denyPublic is true, a public address is rejected with [ErrPublicIP].
// Both checks may be active at the same time, in which case any
// resolved address fails and the caller must rely on an allow-list
// bypass.
func resolveHost(ctx context.Context, host string, denyPrivate, denyPublic bool) ([]netip.Addr, error) {
if host == "" {
return nil, errors.New("empty host")
}
check := func(a netip.Addr) error {
public := IsPublicIP(a)
if denyPublic && public {
return fmt.Errorf("%q: %w", a, ErrPublicIP)
}
if denyPrivate && !public {
return fmt.Errorf("%q: %w", a, ErrNonPublicIP)
}
return nil
}
if addr, err := netip.ParseAddr(host); err == nil {
if err := check(addr); err != nil {
return nil, err
}
return []netip.Addr{addr}, nil
}
addrs, err := outboundResolver.LookupNetIP(ctx, "ip", host)
if err != nil {
return nil, fmt.Errorf("resolve %q: %w", host, err)
}
if len(addrs) == 0 {
return nil, fmt.Errorf("resolve %q: no addresses returned", host)
}
for _, a := range addrs {
if err := check(a); err != nil {
return nil, fmt.Errorf("%q resolves to rejected address %w", host, err)
}
}
return addrs, nil
}
// OutboundDecision is the result of validating an outbound URL via
// [DecideOutbound]. Callers use it to dial the destination either directly
// (operator-approved allow-list match, Bypass true) or via [DialPinned] so
// that the connect targets the IPs resolved at validation time. Passing
// the decision to the dialer closes the window between validation and
// connect that DNS rebinding exploits.
type OutboundDecision struct {
// Bypass is true when an allow-list pattern matched the URL. The
// operator has explicitly opted into the destination; the caller
// should dial directly without an additional IP check.
Bypass bool
// Pinned holds the IPs resolved for the URL host. The caller should
// dial one of these via [DialPinned] to prevent DNS rebinding between
// validation and connect.
Pinned []netip.Addr
}
// outboundDecisionKey is the context key under which an [OutboundDecision]
// is stored.
type outboundDecisionKey struct{}
// outboundProxiedKey is the context key under which [outboundRoundTripper]
// records that the environment proxy will carry this request, so that the
// dialer knows the address it receives is the proxy's rather than the
// destination's.
type outboundProxiedKey struct{}
// decideConfig carries optional settings for [DecideOutbound] and
// [FilterOutboundURL]. See [DecideOption] for how callers configure it.
type decideConfig struct {
denyPrivateIPs bool
denyPublicIPs bool
}
// DecideOption customizes how [DecideOutbound] and [FilterOutboundURL]
// validate a URL. Options are applied in order on top of the permissive
// defaults (no IP-class rejection).
type DecideOption func(*decideConfig)
// WithDenyPrivateIPs rejects URLs whose host resolves to a non-public IP
// address (loopback, RFC1918, link-local, unique-local, multicast,
// unspecified). DNS still runs and the returned [OutboundDecision] still
// carries the resolved IPs for dial pinning, so enabling or disabling
// this option does not affect DNS-rebinding protection. Use it on
// internet-exposed deployments to mitigate SSRF against internal
// services.
func WithDenyPrivateIPs(deny bool) DecideOption {
return func(c *decideConfig) { c.denyPrivateIPs = deny }
}
// WithDenyPublicIPs rejects URLs whose host resolves to a public IP
// address. Use it on air-gapped or data-governed deployments where
// Gotenberg must only reach hosts on a private network; the option
// prevents data exfiltration to attacker-controlled public servers via
// webhook callbacks, downloadFrom URLs, or user-supplied stamp sources.
// May be combined with [WithDenyPrivateIPs]; in that case every resolved
// address fails and only an allow-list bypass permits a destination.
func WithDenyPublicIPs(deny bool) DecideOption {
return func(c *decideConfig) { c.denyPublicIPs = deny }
}
// httpLikeScheme reports whether scheme is one of http, https, ws, or wss.
// Only these schemes go through the IP-based address check; data, blob,
// file, and other schemes are filtered by the regex layer alone.
func httpLikeScheme(scheme string) bool {
switch scheme {
case "http", "https", "ws", "wss":
return true
}
return false
}
// DecideOutbound parses rawURL, runs the regex allow/deny lists against
// the normalized form, and (when no allow-list match) resolves the host
// and applies the IP-class checks selected by opts. It returns the
// resulting [OutboundDecision] so the caller can pin the dial to the IPs
// that were resolved here and skip a second DNS lookup later, which
// closes the DNS rebinding window that affects callers that only receive
// an error from [FilterOutboundURL].
//
// The semantics:
//
// 1. The URL is parsed and its scheme and host lowercased.
// 2. allowList and denyList apply against the normalized form with OR
// semantics. The deny-list always applies.
// 3. For http, https, ws, and wss, the host is resolved and every
// resolved address must satisfy the enabled IP-class checks
// ([WithDenyPrivateIPs], [WithDenyPublicIPs]). An allow-list match
// bypasses the IP-class checks and the returned decision carries
// Bypass true. Otherwise the decision carries Pinned with the
// resolved addresses.
//
// Callers that dial the destination themselves must honor Bypass and
// Pinned: bypassed URLs dial the hostname directly (operator opt-in);
// pinned URLs must dial one of Pinned via [DialPinned].
func DecideOutbound(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time, opts ...DecideOption) (OutboundDecision, error) {
cfg := decideConfig{}
for _, opt := range opts {
opt(&cfg)
}
parsed, err := url.Parse(rawURL)
if err != nil {
return OutboundDecision{}, fmt.Errorf("parse URL %q: %w", rawURL, ErrFiltered)
}
parsed.Scheme = strings.ToLower(parsed.Scheme)
parsed.Host = strings.ToLower(parsed.Host)
normalized := parsed.String()
allowMatched := false
if len(allowList) > 0 {
for _, pattern := range allowList {
clone := regexp2.MustCompile(pattern.String(), 0)
clone.MatchTimeout = time.Until(deadline)
ok, err := clone.MatchString(normalized)
if err != nil {
if time.Now().After(deadline) {
return OutboundDecision{}, context.DeadlineExceeded
}
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
}
if ok {
allowMatched = true
break
}
}
if !allowMatched {
return OutboundDecision{}, fmt.Errorf("'%s' does not match any expression from the allowed list: %w", normalized, ErrFiltered)
}
}
for _, pattern := range denyList {
clone := regexp2.MustCompile(pattern.String(), 0)
clone.MatchTimeout = time.Until(deadline)
ok, err := clone.MatchString(normalized)
if err != nil {
if time.Now().After(deadline) {
return OutboundDecision{}, context.DeadlineExceeded
}
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
}
if ok {
return OutboundDecision{}, fmt.Errorf("'%s' matches the expression from the denied list: %w", normalized, ErrFiltered)
}
}
if allowMatched {
return OutboundDecision{Bypass: true}, nil
}
if !httpLikeScheme(parsed.Scheme) {
return OutboundDecision{}, nil
}
host := parsed.Hostname()
if host == "" {
return OutboundDecision{}, fmt.Errorf("URL %q has no host: %w", rawURL, ErrFiltered)
}
addrs, err := resolveHost(ctx, host, cfg.denyPrivateIPs, cfg.denyPublicIPs)
if err != nil {
switch {
case errors.Is(err, ErrNonPublicIP):
return OutboundDecision{}, fmt.Errorf("'%s' targets a non-public address: %w", normalized, ErrFiltered)
case errors.Is(err, ErrPublicIP):
return OutboundDecision{}, fmt.Errorf("'%s' targets a public address: %w", normalized, ErrFiltered)
default:
return OutboundDecision{}, fmt.Errorf("validate '%s' host: %w", normalized, err)
}
}
return OutboundDecision{Pinned: addrs}, nil
}
// FilterOutboundURL validates that rawURL is acceptable for an outbound
// request from Gotenberg. It is the URL-aware replacement for
// [FilterDeadline] and should be preferred for any new code that filters
// a URL before issuing or instructing an outbound request.
//
// The default behavior is permissive: the URL passes as long as it clears
// the regex allow-list and deny-list. Callers that need IP-class checks
// opt in via [WithDenyPrivateIPs] or [WithDenyPublicIPs]. The deny-list
// always applies and cannot be bypassed by an allow-list match.
func FilterOutboundURL(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time, opts ...DecideOption) error {
_, err := DecideOutbound(ctx, rawURL, allowList, denyList, deadline, opts...)
return err
}
// outboundRoundTripper is an [http.RoundTripper] that validates each
// request URL via [DecideOutbound] and stashes the resulting
// [OutboundDecision] in the request context so that [secureDialContext]
// can pin the dial or bypass the IP check as appropriate. Because the
// http.Client invokes RoundTrip again for each redirect hop, this also
// re-validates redirect targets without a separate CheckRedirect.
type outboundRoundTripper struct {
base http.RoundTripper
allowList []*regexp2.Regexp
denyList []*regexp2.Regexp
opts []DecideOption
// proxyFunc mirrors the transport's own proxy resolution. It is nil unless
// the environment proxy is enabled.
proxyFunc func(*url.URL) (*url.URL, error)
}
// RoundTrip validates req.URL and delegates to the base transport.
func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
deadline, ok := req.Context().Deadline()
if !ok {
deadline = time.Now().Add(30 * time.Second)
}
decision, err := DecideOutbound(req.Context(), req.URL.String(), rt.allowList, rt.denyList, deadline, rt.opts...)
if err != nil {
return nil, err
}
ctx := context.WithValue(req.Context(), outboundDecisionKey{}, decision)
// A request the proxy will not carry is dialed directly, so it still gets
// pinned. Without this, enabling the environment proxy would silently drop
// DNS-rebinding protection for every NO_PROXY host, and for all traffic
// when no proxy variable is set at all.
if rt.proxyFunc != nil {
proxyURL, proxyErr := rt.proxyFunc(req.URL)
if proxyErr == nil && proxyURL != nil {
ctx = context.WithValue(ctx, outboundProxiedKey{}, true)
}
}
return rt.base.RoundTrip(req.WithContext(ctx))
}
// NewOutboundHttpClient returns an [http.Client] that validates every
// outbound request URL via the same logic as [FilterOutboundURL] and
// pins the resulting dial to the resolved IPs.
//
// The client re-validates redirect targets automatically because the
// underlying [http.Client] invokes the wrapping [http.RoundTripper] once
// per hop. This closes the redirect-based SSRF bypass that affects raw
// [http.Client] usage when no CheckRedirect is set.
//
// The default posture is permissive; callers pass [WithDenyPrivateIPs]
// or [WithDenyPublicIPs] to opt into IP-class rejection.
//
// When enableEnvironmentProxy is true, the client routes through the proxy
// defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables,
// including any credentials embedded in those URLs. Dial pinning does not apply
// to a hop the proxy carries, since the proxy owns DNS and egress there; a hop
// the proxy declines, such as a NO_PROXY host, is dialed directly and stays
// pinned. The URL allow/deny and IP-class validation runs either way. Callers
// gate this behind their module's opt-in flag. See
// https://github.com/gotenberg/gotenberg/issues/1592.
func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2.Regexp, enableEnvironmentProxy bool, opts ...DecideOption) *http.Client {
base := http.DefaultTransport.(*http.Transport).Clone()
var proxyFunc func(*url.URL) (*url.URL, error)
if enableEnvironmentProxy {
// Route through the operator's proxy (standard env vars, credentials
// included). httpproxy.FromEnvironment reads the environment now rather
// than caching it process-wide like http.ProxyFromEnvironment.
proxyFunc = httpproxy.FromEnvironment().ProxyFunc()
base.Proxy = func(req *http.Request) (*url.URL, error) {
return proxyFunc(req.URL)
}
// Only a hop the proxy actually carries skips pinning: there the dial
// targets the proxy, not the destination, and the proxy owns DNS. A hop
// the proxy declines is dialed directly and stays pinned.
base.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
if proxied, _ := ctx.Value(outboundProxiedKey{}).(bool); proxied {
return outboundDialer.DialContext(ctx, network, addr)
}
return secureDialContext(ctx, network, addr)
}
} else {
// Default: ignore any proxy environment variables and pin the dial to
// the IPs resolved during validation, closing the DNS-rebinding
// window. Clearing Proxy is deliberate: the cloned default transport
// carries http.ProxyFromEnvironment, which combined with the pinned
// dialer would connect to the destination IP on the proxy's port.
base.Proxy = nil
base.DialContext = secureDialContext
}
return &http.Client{
Timeout: timeout,
Transport: &outboundRoundTripper{
base: base,
allowList: allowList,
denyList: denyList,
opts: opts,
proxyFunc: proxyFunc,
},
}
}
// environmentProxyVariables are the variables golang.org/x/net/http/httpproxy
// reads, in the casing precedence it applies.
var environmentProxyVariables = []string{
"HTTP_PROXY", "http_proxy",
"HTTPS_PROXY", "https_proxy",
"ALL_PROXY", "all_proxy",
}
// ValidateEnvironmentProxyVariables checks that every proxy variable currently
// set can be parsed as a proxy URL.
//
// httpproxy discards a parse error and falls back to a direct connection, so an
// operator who mistypes a proxy URL would silently lose the egress path they
// meant to enforce. Modules exposing an environment proxy flag call this from
// their Validate so that startup fails loudly instead.
//
// Values are never included in the error: a proxy URL may carry credentials.
func ValidateEnvironmentProxyVariables() error {
var err error
for _, name := range environmentProxyVariables {
if os.Getenv(name) == "" {
continue
}
if !isUsableProxyURL(os.Getenv(name)) {
err = errors.Join(err, fmt.Errorf("environment variable %s is not a usable proxy URL; unset it, or set it to a value like 'http://user:password@host:3128'", name))
}
}
return err
}
// isUsableProxyURL mirrors httpproxy's own parsing: a URL with a proxy scheme,
// or anything that becomes one once a scheme is prefixed.
func isUsableProxyURL(value string) bool {
proxyURL, err := url.Parse(value)
if err == nil {
switch proxyURL.Scheme {
case "http", "https", "socks5", "socks5h":
return true
}
}
// httpproxy retries bare values such as "host:3128" with a scheme.
_, err = url.Parse("http://" + value)
return err == nil
}
// secureDialContext consumes the [OutboundDecision] stashed in ctx by
// [outboundRoundTripper]. When the decision is to bypass (allow-list
// match), it dials directly. When the decision contains pinned IPs, it
// dials each in turn until one connects. When no decision is present
// (the dialer was used outside of [outboundRoundTripper]), it falls back
// to resolving the destination without IP-class checks so that the
// fallback matches the permissive default and operators who need
// restrictions configure them at the caller.
func secureDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("split host:port %q: %w", addr, err)
}
if decision, ok := ctx.Value(outboundDecisionKey{}).(OutboundDecision); ok {
if decision.Bypass {
return outboundDialer.DialContext(ctx, network, addr)
}
if len(decision.Pinned) > 0 {
return DialPinned(ctx, network, decision.Pinned, port)
}
}
addrs, err := resolveHost(ctx, host, false, false)
if err != nil {
return nil, err
}
return DialPinned(ctx, network, addrs, port)
}
// DialPinned dials each addr in turn until one connects, returning the
// first successful connection or the last error. Callers pass the Pinned
// slice from [OutboundDecision] so that the dial targets exactly the IPs
// that [DecideOutbound] resolved, preventing DNS rebinding between
// validation and connect.
func DialPinned(ctx context.Context, network string, addrs []netip.Addr, port string) (net.Conn, error) {
var lastErr error
for _, a := range addrs {
conn, err := outboundDialer.DialContext(ctx, network, net.JoinHostPort(a.String(), port))
if err == nil {
return conn, nil
}
lastErr = err
}
if lastErr == nil {
return nil, errors.New("no addresses to dial")
}
return nil, lastErr
}
// DialThroughProxy opens a TCP tunnel to target (a host:port) through the
// HTTP CONNECT proxy at proxyURL, authenticating with any credentials
// embedded in proxyURL. dialProxy dials the proxy's own address; callers pass
// a plain dialer. Chromium and soffice cannot authenticate to a proxy
// themselves, so Gotenberg performs the CONNECT handshake on their behalf.
// The returned connection carries the raw tunnel for the caller to splice
// with the client. See https://github.com/gotenberg/gotenberg/issues/1592.
func DialThroughProxy(ctx context.Context, proxyURL *url.URL, target string, dialProxy func(ctx context.Context, network, addr string) (net.Conn, error)) (net.Conn, error) {
conn, err := dialProxy(ctx, "tcp", proxyHostPort(proxyURL))
if err != nil {
return nil, fmt.Errorf("dial proxy: %w", err)
}
if proxyURL.Scheme == "https" {
tlsConn := tls.Client(conn, &tls.Config{ServerName: proxyURL.Hostname()})
err = tlsConn.HandshakeContext(ctx)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("TLS handshake with proxy: %w", err)
}
conn = tlsConn
}
// Bound the CONNECT handshake by the request deadline; cleared once the
// tunnel is established so splicing manages its own lifetime.
if deadline, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
}
connectReq := &http.Request{
Method: http.MethodConnect,
URL: &url.URL{Opaque: target},
Host: target,
Header: make(http.Header),
}
if user := proxyURL.User; user != nil {
password, _ := user.Password()
connectReq.Header.Set("Proxy-Authorization", proxyAuthHeader(user.Username(), password))
}
err = connectReq.Write(conn)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("write CONNECT to proxy: %w", err)
}
br := bufio.NewReader(conn)
resp, err := http.ReadResponse(br, connectReq)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("read CONNECT response from proxy: %w", err)
}
// A CONNECT response carries no body; discard defensively.
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_ = conn.Close()
return nil, fmt.Errorf("proxy refused CONNECT to %q with status %d", target, resp.StatusCode)
}
_ = conn.SetDeadline(time.Time{})
// The reader may hold bytes the proxy sent right after the response;
// overlay it so those tunnel bytes are not lost when splicing.
return &bufferedConn{Conn: conn, r: br}, nil
}
// proxyHostPort returns proxyURL's host:port, defaulting the port from the
// scheme when the URL omits it.
func proxyHostPort(proxyURL *url.URL) string {
port := proxyURL.Port()
if port == "" {
port = "80"
if proxyURL.Scheme == "https" {
port = "443"
}
}
return net.JoinHostPort(proxyURL.Hostname(), port)
}
// proxyAuthHeader builds a Basic Proxy-Authorization header value.
func proxyAuthHeader(username, password string) string {
return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))
}
// bufferedConn overlays a [bufio.Reader] on a [net.Conn] so that bytes
// buffered while reading a proxy's CONNECT response are not lost when the
// tunnel is spliced.
type bufferedConn struct {
net.Conn
r *bufio.Reader
}
func (c *bufferedConn) Read(b []byte) (int, error) {
return c.r.Read(b)
}
// CloseWrite half-closes the underlying connection. Embedding [net.Conn] hides
// the method, so a CONNECT splice over this connection could never signal EOF
// to the upstream and both sides waited for the other until a timeout.
func (c *bufferedConn) CloseWrite() error {
cw, ok := c.Conn.(interface{ CloseWrite() error })
if !ok {
return fmt.Errorf("underlying %T does not support half-close", c.Conn)
}
return cw.CloseWrite()
}

View File

@@ -0,0 +1,155 @@
package gotenberg
import (
"net"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
)
func TestValidateEnvironmentProxyVariables(t *testing.T) {
for _, tc := range []struct {
name string
env map[string]string
wantErr bool
// wantIn is a substring the error must name, so that an operator can
// find the offending variable.
wantIn string
}{
{
name: "nothing set",
env: map[string]string{},
},
{
name: "well formed URL",
env: map[string]string{"HTTP_PROXY": "http://proxy.example.com:3128"},
},
{
name: "credentials are accepted",
env: map[string]string{"HTTPS_PROXY": "http://user:password@proxy.example.com:3128"},
},
{
name: "bare host and port is accepted, as httpproxy prefixes a scheme",
env: map[string]string{"HTTP_PROXY": "proxy.example.com:3128"},
},
{
name: "socks5 is accepted",
env: map[string]string{"ALL_PROXY": "socks5://proxy.example.com:1080"},
},
{
name: "lowercase variables are checked too",
env: map[string]string{"http_proxy": "http://proxy.example.com:3128"},
},
{
name: "unparseable URL",
env: map[string]string{"HTTP_PROXY": "http://proxy.example.com:3128/%zz"},
wantErr: true,
wantIn: "HTTP_PROXY",
},
{
name: "the failing variable is named",
env: map[string]string{"HTTPS_PROXY": "://%zz"},
wantErr: true,
wantIn: "HTTPS_PROXY",
},
} {
t.Run(tc.name, func(t *testing.T) {
for _, name := range environmentProxyVariables {
t.Setenv(name, "")
}
for name, value := range tc.env {
t.Setenv(name, value)
}
err := ValidateEnvironmentProxyVariables()
if tc.wantErr && err == nil {
t.Fatal("expected an error, got none")
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tc.wantIn != "" && !strings.Contains(err.Error(), tc.wantIn) {
t.Errorf("error %q does not name %q", err, tc.wantIn)
}
})
}
}
// TestValidateEnvironmentProxyVariables_DoesNotLeakCredentials pins that a
// proxy URL, which may embed a password, never reaches the error text.
func TestValidateEnvironmentProxyVariables_DoesNotLeakCredentials(t *testing.T) {
for _, name := range environmentProxyVariables {
t.Setenv(name, "")
}
t.Setenv("HTTP_PROXY", "http://admin:hunter2@proxy.example.com:3128/%zz")
err := ValidateEnvironmentProxyVariables()
if err == nil {
t.Fatal("expected an error, got none")
}
if strings.Contains(err.Error(), "hunter2") {
t.Errorf("error leaks the proxy password: %q", err)
}
if strings.Contains(err.Error(), "admin") {
t.Errorf("error leaks the proxy username: %q", err)
}
}
// TestNewOutboundHttpClient_EnvironmentProxyPinsDirectHops is the regression
// test for the dial-pinning gap: with the environment proxy enabled but no
// proxy applicable to the request, the dial must still go through the pinning
// dialer rather than a plain one.
//
// The request targets a hostname that only the stub resolver knows, so a plain
// dial would hand that unresolvable name to the OS and fail. Only a pinned dial,
// which substitutes the address resolved during validation, can connect.
// See https://github.com/gotenberg/gotenberg/issues/1592.
func TestNewOutboundHttpClient_EnvironmentProxyPinsDirectHops(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
_, port, err := net.SplitHostPort(strings.TrimPrefix(srv.URL, "http://"))
if err != nil {
t.Fatalf("split server address: %v", err)
}
const host = "pinned-only.invalid"
// NO_PROXY covers the destination, so httpproxy declines it and the
// transport dials directly. That direct dial is the hop that used to lose
// pinning.
for _, name := range environmentProxyVariables {
t.Setenv(name, "")
}
t.Setenv("HTTP_PROXY", "http://proxy.invalid:3128")
t.Setenv("NO_PROXY", host)
withStubResolver(t, func(string) ([]netip.Addr, error) {
return []netip.Addr{netip.MustParseAddr("127.0.0.1")}, nil
})
client := NewOutboundHttpClient(0, nil, nil, true)
rt, ok := client.Transport.(*outboundRoundTripper)
if !ok {
t.Fatalf("transport is %T, want *outboundRoundTripper", client.Transport)
}
if rt.proxyFunc == nil {
t.Fatal("proxyFunc is nil, want the environment proxy to be resolved per request")
}
resp, err := client.Get("http://" + net.JoinHostPort(host, port))
if err != nil {
t.Fatalf("GET failed, so the direct hop was not pinned: %v", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusNoContent)
}
}

View File

@@ -0,0 +1,153 @@
package gotenberg
import (
"bufio"
"context"
"encoding/base64"
"io"
"net"
"net/http"
"net/url"
"sync"
"testing"
"time"
)
// connectCapture records the CONNECT request a proxy stub received.
type connectCapture struct {
mu sync.Mutex
method string
host string
auth string
}
func (c *connectCapture) set(method, host, auth string) {
c.mu.Lock()
defer c.mu.Unlock()
c.method, c.host, c.auth = method, host, auth
}
func (c *connectCapture) get() (string, string, string) {
c.mu.Lock()
defer c.mu.Unlock()
return c.method, c.host, c.auth
}
// startConnectProxyStub starts a raw TCP server that behaves like an HTTP
// CONNECT proxy: it reads the CONNECT request, records it, replies 200 with a
// greeting appended to the same write (to exercise buffered-byte handling),
// then echoes tunnel bytes back to the caller.
func startConnectProxyStub(t *testing.T, capture *connectCapture) string {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = l.Close() })
go func() {
conn, err := l.Accept()
if err != nil {
return
}
defer func() { _ = conn.Close() }()
br := bufio.NewReader(conn)
req, err := http.ReadRequest(br)
if err != nil {
return
}
capture.set(req.Method, req.Host, req.Header.Get("Proxy-Authorization"))
// The greeting rides along with the response so the client's CONNECT
// response parser buffers it; bufferedConn must not drop it.
_, _ = conn.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\nTUNNEL-HELLO"))
_, _ = io.Copy(conn, br)
}()
return l.Addr().String()
}
func TestDialThroughProxy(t *testing.T) {
capture := &connectCapture{}
addr := startConnectProxyStub(t, capture)
proxyURL := &url.URL{Scheme: "http", Host: addr, User: url.UserPassword("alice", "s3cr3t")}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, err := DialThroughProxy(ctx, proxyURL, "example.com:443", func(ctx context.Context, network, addr string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, network, addr)
})
if err != nil {
t.Fatalf("DialThroughProxy: %v", err)
}
defer func() { _ = conn.Close() }()
// The greeting buffered while reading the CONNECT response must survive.
greeting := make([]byte, len("TUNNEL-HELLO"))
_, err = io.ReadFull(conn, greeting)
if err != nil {
t.Fatalf("read greeting: %v", err)
}
if string(greeting) != "TUNNEL-HELLO" {
t.Fatalf("greeting = %q, want TUNNEL-HELLO", greeting)
}
// The tunnel must round-trip bytes.
_, err = conn.Write([]byte("ping"))
if err != nil {
t.Fatalf("write to tunnel: %v", err)
}
echo := make([]byte, 4)
_, err = io.ReadFull(conn, echo)
if err != nil {
t.Fatalf("read echo: %v", err)
}
if string(echo) != "ping" {
t.Fatalf("echo = %q, want ping", echo)
}
method, host, auth := capture.get()
if method != http.MethodConnect {
t.Fatalf("proxy saw method %q, want CONNECT", method)
}
if host != "example.com:443" {
t.Fatalf("proxy saw target %q, want example.com:443", host)
}
wantAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("alice:s3cr3t"))
if auth != wantAuth {
t.Fatalf("proxy saw Proxy-Authorization %q, want %q", auth, wantAuth)
}
}
func TestDialThroughProxy_RefusedStatus(t *testing.T) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = l.Close() })
go func() {
conn, err := l.Accept()
if err != nil {
return
}
defer func() { _ = conn.Close() }()
br := bufio.NewReader(conn)
_, _ = http.ReadRequest(br)
_, _ = conn.Write([]byte("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n"))
}()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = DialThroughProxy(ctx, &url.URL{Scheme: "http", Host: l.Addr().String()}, "example.com:443", func(ctx context.Context, network, addr string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, network, addr)
})
if err == nil {
t.Fatal("expected an error when the proxy refuses CONNECT, got nil")
}
}

View File

@@ -0,0 +1,489 @@
package gotenberg
import (
"context"
"errors"
"net/netip"
"testing"
"time"
"github.com/dlclark/regexp2"
)
func TestIsPublicIP(t *testing.T) {
for _, tc := range []struct {
addr string
public bool
}{
// Public.
{"1.1.1.1", true},
{"8.8.8.8", true},
{"2606:4700:4700::1111", true},
// Loopback.
{"127.0.0.1", false},
{"127.255.255.254", false},
{"::1", false},
// IPv4-mapped IPv6 (Issue 2).
{"::ffff:127.0.0.1", false},
{"::ffff:10.0.0.1", false},
{"::ffff:169.254.169.254", false},
// RFC1918.
{"10.0.0.1", false},
{"172.16.0.1", false},
{"172.31.255.254", false},
{"192.168.1.1", false},
// Link-local.
{"169.254.169.254", false},
{"fe80::1", false},
// Unique-local.
{"fc00::1", false},
{"fd12:3456:789a::1", false},
// Unspecified.
{"0.0.0.0", false},
{"::", false},
// Multicast.
{"224.0.0.1", false},
{"ff02::1", false},
// 6to4 wrapping internal/private IPv4 (RFC 3056, deprecated by
// RFC 7526). a9fe:a9fe = 169.254.169.254 (cloud metadata).
{"2002:a9fe:a9fe::", false},
{"2002:0a00:0001::", false},
{"2002:c0a8:0101::", false},
// 6to4 wrapping a public IPv4 (8.8.8.8) is rejected wholesale.
{"2002:0808:0808::", false},
// NAT64 well-known prefix (RFC 6052).
{"64:ff9b::a9fe:a9fe", false},
{"64:ff9b::0808:0808", false},
// NAT64 local-use prefix (RFC 8215).
{"64:ff9b:1::a9fe:a9fe", false},
// Teredo (RFC 4380).
{"2001:0:abcd:ef12:3456:7890:a9fe:a9fe", false},
// Deprecated site-local (RFC 3879).
{"fec0::1", false},
{"feff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", false},
// IPv4-compatible IPv6 (deprecated, not handled by Unmap).
{"::a9fe:a9fe", false},
// Documentation prefix (RFC 3849).
{"2001:db8::1", false},
// Discard prefix (RFC 6666).
{"100::1", false},
} {
t.Run(tc.addr, func(t *testing.T) {
addr, err := netip.ParseAddr(tc.addr)
if err != nil {
t.Fatalf("parse %q: %v", tc.addr, err)
}
if got := IsPublicIP(addr); got != tc.public {
t.Fatalf("IsPublicIP(%q) = %v, want %v", tc.addr, got, tc.public)
}
})
}
}
// stubResolver lets tests fake DNS lookups in [ResolveAndCheckPublic].
type stubResolver struct {
lookup func(host string) ([]netip.Addr, error)
}
func (s stubResolver) LookupNetIP(_ context.Context, _, host string) ([]netip.Addr, error) {
return s.lookup(host)
}
func withStubResolver(t *testing.T, fn func(host string) ([]netip.Addr, error)) {
t.Helper()
prev := outboundResolver
outboundResolver = stubResolver{lookup: fn}
t.Cleanup(func() { outboundResolver = prev })
}
func mustAddrs(t *testing.T, ss ...string) []netip.Addr {
t.Helper()
out := make([]netip.Addr, 0, len(ss))
for _, s := range ss {
a, err := netip.ParseAddr(s)
if err != nil {
t.Fatalf("parse %q: %v", s, err)
}
out = append(out, a)
}
return out
}
func TestFilterOutboundURL(t *testing.T) {
defaultDeny := []*regexp2.Regexp{
regexp2.MustCompile(`^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)`, 0),
}
chromiumDeny := []*regexp2.Regexp{
regexp2.MustCompile(`^file:(?!//\/tmp/).*`, 0),
}
for _, tc := range []struct {
scenario string
rawURL string
allow []*regexp2.Regexp
deny []*regexp2.Regexp
opts []DecideOption
stub func(host string) ([]netip.Addr, error)
expectErr bool
expectIs error
expectErrMsg string
}{
{
scenario: "public IP literal passes",
rawURL: "https://1.1.1.1/",
deny: defaultDeny,
expectErr: false,
},
{
scenario: "loopback IP literal blocked by default deny-list",
rawURL: "http://127.0.0.1:8080/",
deny: defaultDeny,
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "Issue 4: uppercase scheme normalized then blocked by deny-list",
rawURL: "HTTP://127.0.0.1:8080/",
deny: defaultDeny,
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "Issue 2: IPv4-mapped IPv6 evades deny-list but blocked by IP check",
rawURL: "http://[::ffff:127.0.0.1]:8080/page.pdf",
deny: defaultDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "Issue 2: IPv4-mapped IPv6 to RFC1918 blocked by IP check",
rawURL: "http://[::ffff:10.0.0.1]/",
deny: defaultDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "hostname resolving to public IP passes with deny-private-ips",
rawURL: "https://example.com/",
deny: defaultDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "93.184.216.34"), nil },
expectErr: false,
},
{
scenario: "hostname resolving to loopback blocked with deny-private-ips",
rawURL: "https://rebind.example/",
deny: defaultDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "127.0.0.1"), nil },
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "hostname resolving to mixed public+private blocked with deny-private-ips",
rawURL: "https://mixed.example/",
deny: defaultDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "1.1.1.1", "10.0.0.1"), nil },
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "allow-list match bypasses IP check",
rawURL: "http://internal.service/api",
allow: []*regexp2.Regexp{regexp2.MustCompile(`^http://internal\.service`, 0)},
deny: defaultDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
expectErr: false,
},
{
scenario: "deny-list still wins over allow-list match",
rawURL: "http://internal.service/api",
allow: []*regexp2.Regexp{regexp2.MustCompile(`^http://internal`, 0)},
deny: []*regexp2.Regexp{regexp2.MustCompile(`/api$`, 0)},
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "allow-list non-empty and no match rejects",
rawURL: "https://other.example/",
allow: []*regexp2.Regexp{regexp2.MustCompile(`^https://allowed\.example`, 0)},
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "file:// allowed under tmp passes Chromium default",
rawURL: "file:///tmp/index.html",
deny: chromiumDeny,
expectErr: false,
},
{
scenario: "file:// outside tmp blocked by Chromium default",
rawURL: "file:///etc/passwd",
deny: chromiumDeny,
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "Chromium default permissive passes http to public host",
rawURL: "https://example.com/",
deny: chromiumDeny,
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "93.184.216.34"), nil },
expectErr: false,
},
{
scenario: "Chromium with deny-private-ips blocks http to loopback",
rawURL: "http://127.0.0.1:3000/health",
deny: chromiumDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "Chromium with deny-private-ips blocks cloud metadata",
rawURL: "http://169.254.169.254/latest/meta-data/",
deny: chromiumDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "data: URL passes (non-network scheme)",
rawURL: "data:text/html;base64,PGgxPmhpPC9oMT4=",
expectErr: false,
},
{
scenario: "URL with no host rejected",
rawURL: "http:///path",
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "userinfo cannot mask host when deny-private-ips enabled",
rawURL: "http://example.com@127.0.0.1/",
deny: defaultDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
expectErr: true,
expectIs: ErrFiltered,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
if tc.stub != nil {
withStubResolver(t, tc.stub)
} else {
// Default: any DNS lookup in a non-stubbed test is a bug.
withStubResolver(t, func(host string) ([]netip.Addr, error) {
t.Fatalf("unexpected DNS lookup for %q", host)
return nil, nil
})
}
err := FilterOutboundURL(context.Background(), tc.rawURL, tc.allow, tc.deny, time.Now().Add(5*time.Second), tc.opts...)
if tc.expectErr && err == nil {
t.Fatalf("expected error, got nil")
}
if !tc.expectErr && err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if tc.expectIs != nil && !errors.Is(err, tc.expectIs) {
t.Fatalf("expected error to wrap %v, got: %v", tc.expectIs, err)
}
})
}
}
func TestResolveAndCheckPublic_IPLiteralLoopback(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
t.Fatalf("unexpected DNS lookup for %q", host)
return nil, nil
})
_, err := ResolveAndCheckPublic(context.Background(), "127.0.0.1")
if !errors.Is(err, ErrNonPublicIP) {
t.Fatalf("expected ErrNonPublicIP, got: %v", err)
}
}
func TestResolveAndCheckPublic_HostResolvesToLoopback(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
return mustAddrs(t, "127.0.0.1"), nil
})
_, err := ResolveAndCheckPublic(context.Background(), "rebind.example")
if !errors.Is(err, ErrNonPublicIP) {
t.Fatalf("expected ErrNonPublicIP, got: %v", err)
}
}
func TestResolveAndCheckPublic_HostResolvesToPublic(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
return mustAddrs(t, "1.1.1.1"), nil
})
addrs, err := ResolveAndCheckPublic(context.Background(), "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(addrs) != 1 || addrs[0].String() != "1.1.1.1" {
t.Fatalf("expected [1.1.1.1], got: %v", addrs)
}
}
func TestDecideOutbound_DenyPrivateIPs_RejectsLoopbackLiteral(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
t.Fatalf("unexpected DNS lookup for %q", host)
return nil, nil
})
_, err := DecideOutbound(
context.Background(),
"http://127.0.0.1:8080/",
nil, nil,
time.Now().Add(5*time.Second),
WithDenyPrivateIPs(true),
)
if !errors.Is(err, ErrFiltered) {
t.Fatalf("WithDenyPrivateIPs(true) must reject loopback literal, got: %v", err)
}
}
func TestDecideOutbound_DenyPrivateIPs_AllowsPublic(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
return mustAddrs(t, "93.184.216.34"), nil
})
decision, err := DecideOutbound(
context.Background(),
"http://example.com/",
nil, nil,
time.Now().Add(5*time.Second),
WithDenyPrivateIPs(true),
)
if err != nil {
t.Fatalf("expected no error for public host, got: %v", err)
}
if len(decision.Pinned) != 1 || decision.Pinned[0].String() != "93.184.216.34" {
t.Fatalf("decision.Pinned = %v, want [93.184.216.34]", decision.Pinned)
}
}
func TestDecideOutbound_DenyPublicIPs_RejectsPublic(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
return mustAddrs(t, "1.1.1.1"), nil
})
_, err := DecideOutbound(
context.Background(),
"http://example.com/",
nil, nil,
time.Now().Add(5*time.Second),
WithDenyPublicIPs(true),
)
if !errors.Is(err, ErrFiltered) {
t.Fatalf("WithDenyPublicIPs(true) must reject public host, got: %v", err)
}
}
func TestDecideOutbound_DenyPublicIPs_AllowsPrivate(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
return mustAddrs(t, "10.0.0.5"), nil
})
decision, err := DecideOutbound(
context.Background(),
"http://internal.svc/",
nil, nil,
time.Now().Add(5*time.Second),
WithDenyPublicIPs(true),
)
if err != nil {
t.Fatalf("expected no error for private host, got: %v", err)
}
if len(decision.Pinned) != 1 || decision.Pinned[0].String() != "10.0.0.5" {
t.Fatalf("decision.Pinned = %v, want [10.0.0.5]", decision.Pinned)
}
}
func TestDecideOutbound_DenyBoth_WhitelistOnly(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
return mustAddrs(t, "1.1.1.1"), nil
})
// Both denies active and no allow-list match: every resolved address
// fails. Only an allow-list match can permit a destination under
// this posture.
_, err := DecideOutbound(
context.Background(),
"http://example.com/",
nil, nil,
time.Now().Add(5*time.Second),
WithDenyPrivateIPs(true),
WithDenyPublicIPs(true),
)
if !errors.Is(err, ErrFiltered) {
t.Fatalf("expected ErrFiltered with both denies enabled, got: %v", err)
}
}
func TestDecideOutbound_DenyLists_WinOverDenyPrivateIPs(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
t.Fatalf("unexpected DNS lookup for %q", host)
return nil, nil
})
// The regex deny-list fires before any resolution; verifies that
// operator-supplied deny patterns remain effective regardless of
// IP-class options.
deny := []*regexp2.Regexp{regexp2.MustCompile(`^http://evil\.`, 0)}
_, err := DecideOutbound(
context.Background(),
"http://evil.local/",
nil, deny,
time.Now().Add(5*time.Second),
WithDenyPrivateIPs(true),
)
if !errors.Is(err, ErrFiltered) {
t.Fatalf("deny-list must still reject, got: %v", err)
}
}
func TestDecideOutbound_Permissive_AllowsPrivate(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
return mustAddrs(t, "10.0.0.5"), nil
})
// No options passed: default posture is permissive across both
// IP classes. The caller still gets pinned IPs for dial safety.
decision, err := DecideOutbound(
context.Background(),
"http://internal.svc/",
nil, nil,
time.Now().Add(5*time.Second),
)
if err != nil {
t.Fatalf("permissive default must allow private host, got: %v", err)
}
if len(decision.Pinned) != 1 || decision.Pinned[0].String() != "10.0.0.5" {
t.Fatalf("decision.Pinned = %v, want [10.0.0.5]", decision.Pinned)
}
}

View File

@@ -35,6 +35,10 @@ var (
// ErrPdfRotateAngleNotSupported is returned when the rotation angle is
// not supported.
ErrPdfRotateAngleNotSupported = errors.New("rotation angle not supported")
// ErrPdfFacturXValueNotSupported is returned when a Factur-X field value
// (e.g., an unknown conformance level or document type) is not supported.
ErrPdfFacturXValueNotSupported = errors.New("Factur-X value not supported")
)
// PdfEngineInvalidArgsError represents an error returned by a PDF engine when
@@ -143,6 +147,56 @@ type PdfFormats struct {
PdfUa bool
}
// PdfPermissions gathers the document permissions enforced when a PDF is
// encrypted. Each field defaults to true (the action is allowed); set a field
// to false to restrict it. Restrictions are advisory: viewers honor them, but
// they are not cryptographically enforced once the document opens.
type PdfPermissions struct {
// AllowPrinting permits printing the document.
AllowPrinting bool
// AllowCopying permits extracting text and graphics.
AllowCopying bool
// AllowModifying permits changing the document content.
AllowModifying bool
// AllowAnnotating permits adding or modifying annotations.
AllowAnnotating bool
// AllowFillingForms permits filling in form fields.
AllowFillingForms bool
// AllowAssembling permits inserting, deleting, and rotating pages.
AllowAssembling bool
}
// Restricted reports whether at least one permission is denied.
func (p PdfPermissions) Restricted() bool {
return !p.AllowPrinting ||
!p.AllowCopying ||
!p.AllowModifying ||
!p.AllowAnnotating ||
!p.AllowFillingForms ||
!p.AllowAssembling
}
// EncryptOptions gathers the parameters for encrypting a PDF. An empty
// UserPassword with a set OwnerPassword produces an owner-only document: it
// opens without a password but enforces the [PdfPermissions].
type EncryptOptions struct {
// UserPassword is required to open the document. Empty means no open
// password.
UserPassword string
// OwnerPassword grants full access (lifts the permission restrictions).
// When empty, it defaults to UserPassword.
OwnerPassword string
// Permissions are the actions allowed when opened with the user password.
Permissions PdfPermissions
}
// Bookmark represents a node in the PDF document's outline
// (table of contents).
type Bookmark struct {
@@ -151,6 +205,59 @@ type Bookmark struct {
Children []Bookmark `json:"children,omitempty"`
}
const (
// FacturXConformanceMinimum represents the MINIMUM Factur-X conformance level.
FacturXConformanceMinimum string = "MINIMUM"
// FacturXConformanceBasicWL represents the BASIC WL Factur-X conformance level.
FacturXConformanceBasicWL string = "BASIC WL"
// FacturXConformanceBasic represents the BASIC Factur-X conformance level.
FacturXConformanceBasic string = "BASIC"
// FacturXConformanceEN16931 represents the EN 16931 Factur-X conformance level.
FacturXConformanceEN16931 string = "EN 16931"
// FacturXConformanceExtended represents the EXTENDED Factur-X conformance level.
FacturXConformanceExtended string = "EXTENDED"
// FacturXConformanceXRechnung represents the XRECHNUNG Factur-X conformance level.
FacturXConformanceXRechnung string = "XRECHNUNG"
// FacturXDocumentTypeInvoice represents the INVOICE Factur-X document type.
FacturXDocumentTypeInvoice string = "INVOICE"
// FacturXDocumentTypeOrder represents the ORDER Factur-X document type.
FacturXDocumentTypeOrder string = "ORDER"
// FacturXDocumentTypeOrderResponse represents the ORDER_RESPONSE Factur-X document type.
FacturXDocumentTypeOrderResponse string = "ORDER_RESPONSE"
// FacturXDocumentTypeOrderChange represents the ORDER_CHANGE Factur-X document type.
FacturXDocumentTypeOrderChange string = "ORDER_CHANGE"
// FacturXDocumentFileName is the canonical name of the embedded XML invoice
// mandated by the Factur-X standard. Validators expect this exact name.
FacturXDocumentFileName string = "factur-x.xml"
)
// FacturX gathers the properties required by the Factur-X/ZUGFeRD standard for
// the document-level XMP metadata packet of a PDF/A-3.
type FacturX struct {
// ConformanceLevel is one of the FacturXConformance* values.
ConformanceLevel string
// DocumentType is one of the FacturXDocumentType* values.
DocumentType string
// DocumentFileName is the name of the embedded XML invoice. It is set
// internally to the canonical [FacturXDocumentFileName], not by the caller.
DocumentFileName string
// Version is the Factur-X version (e.g., "1.0").
Version string
}
// PdfEngine provides an interface for operations on PDFs. Implementations
// can use various tools like PDFtk, or implement functionality directly in
// Go.
@@ -190,17 +297,23 @@ type PdfEngine interface {
// The bookmarks parameter represents the hierarchical tree of the outline.
WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
// Encrypt adds password protection to a PDF file.
// The userPassword is required to open the document.
// The ownerPassword provides full access to the document.
// If the ownerPassword is empty, it defaults to the userPassword.
Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
// Encrypt adds password protection and permission restrictions to a PDF
// file, as described by [EncryptOptions]. An empty user password with a set
// owner password yields an owner-only document (opens without a password,
// permissions enforced).
Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts EncryptOptions) error
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
// without modifying the main PDF content.
// TODO: attachments instead? Rename the route?
EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
// EmbedFilesMetadata sets metadata (such as MIME type and AFRelationship)
// on already-embedded files in a PDF. The metadata map is keyed by
// filename, with each value being a map of property names to values
// (e.g., "mimeType" and "relationship").
EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error
// Watermark applies a watermark (behind page content) to a PDF file.
Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
@@ -210,6 +323,18 @@ type PdfEngine interface {
// Rotate rotates pages of a PDF file by the given angle (90, 180, 270).
// If pages is empty, all pages are rotated.
Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
// InjectFacturXXMP injects Factur-X/ZUGFeRD XMP metadata into the
// document-level XMP packet (Catalog /Metadata stream) of a PDF/A-3. It
// registers the fx namespace, the four fx properties, and the matching
// PDF/A extension schema so the result stays PDF/A-valid.
InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX FacturX, inputPath string) error
// ReadPdfAConformance reads the PDF/A part and conformance (e.g., "3" and
// "B") from the document-level XMP packet (Catalog /Metadata stream,
// pdfaid:part and pdfaid:conformance). It returns empty strings when the
// document carries no PDF/A identification.
ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (part string, conformance string, err error)
}
// PdfEngineProvider offers an interface to instantiate a [PdfEngine].

View File

@@ -8,6 +8,10 @@ import (
"sync"
"sync/atomic"
"time"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
// ErrProcessAlreadyRestarting happens if the [ProcessSupervisor] is trying
@@ -76,22 +80,45 @@ type ProcessSupervisor interface {
// ActiveTasksCount returns the current number of active tasks.
ActiveTasksCount() int64
// ConversionsSinceRestart returns the number of tasks handled since the
// last process (re)start.
ConversionsSinceRestart() int64
}
// healthCheckCacheTTL caches successful health probe results so kubelet-
// style probes (liveness + readiness, every few seconds each) do not
// hammer the underlying process with CDP roundtrips on every call.
// Tuned to bridge typical probe periods while still catching outages
// quickly: a real outage surfaces on the next probe after the TTL
// elapses.
const healthCheckCacheTTL = 2 * time.Second
// healthFailureThreshold is the number of consecutive Healthy() failures
// the supervisor tolerates before reporting unhealthy. Absorbs single-
// probe blips of transient CDP latency (for example a slow
// Browser.getVersion roundtrip when several conversion slots are
// simultaneously stuck), without delaying detection of a real outage.
// The container orchestrator's own failureThreshold stacks on top of
// this. See https://github.com/gotenberg/gotenberg/issues/1561.
const healthFailureThreshold = 2
type processSupervisor struct {
logger *slog.Logger
engine string
process Process
maxReqLimit int64
maxQueueSize int64
maxConcurrency int64
semaphore chan struct{}
firstStart atomic.Bool
firstStartOnce sync.Once
// firstStartErr stores the error from the first Launch attempt executed
// via firstStartOnce. Subsequent callers that enter the !firstStart block
// need to observe this value after the Once has completed, without
// re-executing the closure.
firstStartErr error
// firstStartMu serializes lazy-launch attempts so concurrent callers do
// not all spawn Launch() simultaneously. Using a mutex (instead of
// sync.Once) lets a failed launch be retried by the next caller, since a
// transient failure (such as a cold-start timeout) must not poison the
// supervisor for the rest of the container's lifetime. See
// https://github.com/gotenberg/gotenberg/issues/1538.
firstStartMu sync.Mutex
reqCounter atomic.Int64
reqQueueSize atomic.Int64
restartsCounter atomic.Int64
@@ -99,19 +126,32 @@ type processSupervisor struct {
activeTasks atomic.Int64
restartMutex sync.Mutex
idleShutdownTimeout time.Duration
lastActivity atomic.Int64 // unix nano timestamp of last completed task
idleMu sync.Mutex // protects idleStopChan
idleStopChan chan struct{} // signal to stop the idle ticker goroutine
lastActivity atomic.Int64 // unix nano timestamp of last completed task
// healthMu serializes Healthy() probes so concurrent callers do not
// all issue a CDP roundtrip; the second caller hits the refreshed
// cache instead.
healthMu sync.Mutex
lastHealthyAt atomic.Int64 // unix nano of last successful probe; 0 means never
consecutiveHealthFailures atomic.Int64 // reset to 0 on every successful probe
idleMu sync.Mutex // protects idleStopChan
idleStopChan chan struct{} // signal to stop the idle ticker goroutine
}
// NewProcessSupervisor initializes a new [ProcessSupervisor].
func NewProcessSupervisor(logger *slog.Logger, process Process, maxReqLimit, maxQueueSize, maxConcurrency int64, idleShutdownTimeout time.Duration) ProcessSupervisor {
// NewProcessSupervisor initializes a new [ProcessSupervisor]. engine names the
// managed process (for example "chromium" or "libreoffice") and prefixes the
// telemetry sub-spans; an empty engine falls back to "process".
func NewProcessSupervisor(logger *slog.Logger, engine string, process Process, maxReqLimit, maxQueueSize, maxConcurrency int64, idleShutdownTimeout time.Duration) ProcessSupervisor {
if maxConcurrency < 1 {
maxConcurrency = 1
}
if engine == "" {
engine = "process"
}
b := &processSupervisor{
logger: logger,
engine: engine,
process: process,
semaphore: make(chan struct{}, maxConcurrency),
maxReqLimit: maxReqLimit,
@@ -194,15 +234,69 @@ func (s *processSupervisor) Healthy() bool {
}
if s.isRestarting.Load() {
// A restarting process is not yet healthy — this gives load balancers
// A restarting process is not yet healthy. This gives load balancers
// honest information so they can avoid routing traffic to this node.
return false
}
return s.process.Healthy(s.logger)
// Cache hit: a recent probe succeeded. Skip the CDP roundtrip so probe
// spam does not pile commands onto a busy websocket.
if s.recentlyHealthy() {
return true
}
// Serialize probes so concurrent callers do not all roundtrip. The
// second caller will see the refreshed cache (or counter) and return
// without re-probing.
s.healthMu.Lock()
defer s.healthMu.Unlock()
if s.recentlyHealthy() {
return true
}
if s.process.Healthy(s.logger) {
s.lastHealthyAt.Store(time.Now().UnixNano())
s.consecutiveHealthFailures.Store(0)
return true
}
if s.consecutiveHealthFailures.Add(1) < healthFailureThreshold {
// First failure: tolerate it. Under load, a single blown CDP
// timeout is more likely transient pressure than a dead process.
// A genuinely dead process will fail the next probe as well and
// flip us unhealthy then.
return true
}
return false
}
// recentlyHealthy reports whether a successful probe landed within
// [healthCheckCacheTTL]. Negative results are never cached so recovery
// from a real outage is observable on the very next probe.
func (s *processSupervisor) recentlyHealthy() bool {
last := s.lastHealthyAt.Load()
if last == 0 {
return false
}
return time.Since(time.Unix(0, last)) < healthCheckCacheTTL
}
func (s *processSupervisor) Run(ctx context.Context, logger *slog.Logger, task func() error) error {
// Time spent before the task body runs: queueing, slot acquisition, lazy
// launch, and health checks. Ended once, when the task is about to execute.
_, queueSpan := Tracer().Start(ctx, s.engine+".queue.wait",
trace.WithSpanKind(trace.SpanKindInternal),
)
queueWaitDone := false
endQueueWait := func() {
if !queueWaitDone {
queueWaitDone = true
queueSpan.End()
}
}
defer endQueueWait()
// Atomically check and increment the queue size to avoid the TOCTOU race
// originally reported in https://github.com/gotenberg/gotenberg/issues/951.
for {
@@ -251,6 +345,7 @@ func (s *processSupervisor) Run(ctx context.Context, logger *slog.Logger, task f
return err
}
endQueueWait()
err := s.runWithDeadline(ctx, task)
if s.maybeRestartAfterTask(logger) {
@@ -346,8 +441,6 @@ func (s *processSupervisor) maybeIdleShutdown() {
// Reset state so ensureStarted() re-launches on next request.
s.firstStart.Store(false)
s.firstStartOnce = sync.Once{}
s.firstStartErr = nil
s.reqCounter.Store(0)
s.logger.DebugContext(context.Background(), "process stopped due to idle timeout")
@@ -375,23 +468,50 @@ func (s *processSupervisor) acquireSlot(ctx context.Context, logger *slog.Logger
}
}
// ensureStarted performs a one-time lazy launch of the process on its first
// use. Subsequent calls are no-ops.
// ensureStarted performs a lazy launch of the process on its first use.
// Concurrent callers serialize on firstStartMu; once the launch succeeds,
// subsequent calls short-circuit on the firstStart flag. A failed launch
// leaves firstStart unset, so the next caller retries the launch.
func (s *processSupervisor) ensureStarted(ctx context.Context) error {
if s.firstStart.Load() {
return nil
}
s.firstStartOnce.Do(func() {
s.firstStartErr = s.runWithDeadline(ctx, func() error {
return s.Launch()
})
})
s.firstStartMu.Lock()
defer s.firstStartMu.Unlock()
if s.firstStartErr != nil {
return fmt.Errorf("process first start: %w", s.firstStartErr)
if s.firstStart.Load() {
return nil
}
err := s.tracedLaunch(ctx, "first_start", func() error {
return s.runWithDeadline(ctx, s.Launch)
})
if err != nil {
return fmt.Errorf("process first start: %w", err)
}
return nil
}
// tracedLaunch wraps a process (re)start in an <engine>.process.start span,
// tagged with the reason that triggered it. The eager restart after the maximum
// request limit runs on a background context, so its span is a detached root.
func (s *processSupervisor) tracedLaunch(ctx context.Context, reason string, launch func() error) error {
_, span := Tracer().Start(ctx, s.engine+".process.start",
trace.WithSpanKind(trace.SpanKindInternal),
trace.WithAttributes(attribute.String("gotenberg.process.start.reason", reason)),
)
defer span.End()
err := launch()
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
span.SetStatus(codes.Ok, "")
return nil
}
@@ -405,7 +525,7 @@ func (s *processSupervisor) ensureHealthy(ctx context.Context) error {
s.logger.DebugContext(context.Background(), "process is unhealthy, cannot handle task, restarting...")
if err := s.doRestart(ctx); err != nil {
if err := s.doRestart(ctx, "unhealthy"); err != nil {
return fmt.Errorf("process restart before task: %w", err)
}
@@ -428,7 +548,7 @@ func (s *processSupervisor) maybeRestartAfterTask(logger *slog.Logger) bool {
s.logger.DebugContext(context.Background(), "max request limit reached, restarting eagerly...")
go func() {
restartErr := s.doRestartLocked(context.Background())
restartErr := s.doRestartLocked(context.Background(), "max_requests")
s.restartMutex.Unlock()
if restartErr != nil {
s.logger.ErrorContext(context.Background(), fmt.Sprintf("process restart after task: %v", restartErr))
@@ -442,15 +562,15 @@ func (s *processSupervisor) maybeRestartAfterTask(logger *slog.Logger) bool {
// doRestart coordinates a process restart, draining all active concurrent
// tasks before stopping and restarting the process.
func (s *processSupervisor) doRestart(ctx context.Context) error {
func (s *processSupervisor) doRestart(ctx context.Context, reason string) error {
s.restartMutex.Lock()
defer s.restartMutex.Unlock()
return s.doRestartLocked(ctx)
return s.doRestartLocked(ctx, reason)
}
// doRestartLocked performs the restart drain logic. The caller must hold restartMutex.
func (s *processSupervisor) doRestartLocked(ctx context.Context) error {
func (s *processSupervisor) doRestartLocked(ctx context.Context, reason string) error {
s.isRestarting.Store(true)
defer s.isRestarting.Store(false)
@@ -470,8 +590,8 @@ func (s *processSupervisor) doRestartLocked(ctx context.Context) error {
}
}
err := s.runWithDeadline(ctx, func() error {
return s.restart()
err := s.tracedLaunch(ctx, reason, func() error {
return s.runWithDeadline(ctx, s.restart)
})
for range acquired {
@@ -509,6 +629,13 @@ func (s *processSupervisor) ActiveTasksCount() int64 {
return s.activeTasks.Load()
}
// ConversionsSinceRestart returns the number of tasks handled since the last
// process (re)start. reqCounter is reset to zero on every restart and idle
// shutdown.
func (s *processSupervisor) ConversionsSinceRestart() int64 {
return s.reqCounter.Load()
}
// Interface guards.
var (
_ ProcessSupervisor = (*processSupervisor)(nil)

View File

@@ -45,7 +45,7 @@ func TestProcessSupervisor_Launch(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
if tc.firstStartSet {
ps.firstStart.Store(true)
}
@@ -93,7 +93,7 @@ func TestProcessSupervisor_Shutdown(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0)
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0)
err := ps.Shutdown()
if !tc.expectError && err != nil {
@@ -145,7 +145,7 @@ func TestProcessSupervisor_restart(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
err := ps.restart()
@@ -186,10 +186,10 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
expectHealthy: true,
},
{
scenario: "process reports as unhealthy",
scenario: "single probe failure is tolerated",
initiallyStarted: true,
processHealthy: false,
expectHealthy: false,
expectHealthy: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
@@ -201,7 +201,7 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
if tc.initiallyStarted {
ps.firstStart.Store(true)
}
@@ -218,6 +218,109 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
}
}
// TestProcessSupervisor_Healthy_ConsecutiveFailures verifies that only
// the second consecutive process-level failure flips the supervisor to
// unhealthy, and that a single success in between resets the counter.
func TestProcessSupervisor_Healthy_ConsecutiveFailures(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
var processHealthy atomic.Bool
process := &ProcessMock{
HealthyMock: func(_ *slog.Logger) bool { return processHealthy.Load() },
}
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
ps.firstStart.Store(true)
processHealthy.Store(false)
if !ps.Healthy() {
t.Fatal("first failure should be tolerated and report healthy")
}
if ps.Healthy() {
t.Fatal("second consecutive failure should report unhealthy")
}
processHealthy.Store(true)
if !ps.Healthy() {
t.Fatal("recovery should report healthy immediately")
}
processHealthy.Store(false)
// Cache hit from the previous success absorbs the first new failure;
// invalidate it so we exercise the counter again.
ps.lastHealthyAt.Store(0)
if !ps.Healthy() {
t.Fatal("post-recovery first failure should be tolerated again")
}
if ps.Healthy() {
t.Fatal("post-recovery second consecutive failure should report unhealthy")
}
}
// TestProcessSupervisor_Healthy_CachesPositiveResult verifies that a
// successful probe is cached for [healthCheckCacheTTL] so subsequent
// supervisor.Healthy() calls do not re-issue the underlying process
// check.
func TestProcessSupervisor_Healthy_CachesPositiveResult(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
var calls atomic.Int64
process := &ProcessMock{
HealthyMock: func(_ *slog.Logger) bool {
calls.Add(1)
return true
},
}
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
ps.firstStart.Store(true)
for range 5 {
if !ps.Healthy() {
t.Fatal("expected healthy")
}
}
if got := calls.Load(); got != 1 {
t.Fatalf("process.Healthy called %d times, want exactly 1 (cache should absorb the other 4)", got)
}
}
// TestProcessSupervisor_Healthy_DoesNotCacheNegativeResult verifies that
// a probe failure is not cached: the next Healthy() call must re-issue
// the underlying process check so a recovered process surfaces on the
// very next probe.
func TestProcessSupervisor_Healthy_DoesNotCacheNegativeResult(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
var calls atomic.Int64
var processHealthy atomic.Bool
process := &ProcessMock{
HealthyMock: func(_ *slog.Logger) bool {
calls.Add(1)
return processHealthy.Load()
},
}
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
ps.firstStart.Store(true)
processHealthy.Store(false)
_ = ps.Healthy()
_ = ps.Healthy()
if got := calls.Load(); got != 2 {
t.Fatalf("after two failing probes, process.Healthy called %d times, want 2 (negative results must not be cached)", got)
}
processHealthy.Store(true)
if !ps.Healthy() {
t.Fatal("expected healthy on recovery")
}
if got := calls.Load(); got != 3 {
t.Fatalf("after recovery, process.Healthy called %d times, want 3", got)
}
}
func TestProcessSupervisor_Run(t *testing.T) {
for _, tc := range []struct {
scenario string
@@ -386,7 +489,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit, tc.maxQueueSize, 1, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, "test", process, tc.maxReqLimit, tc.maxQueueSize, 1, 0).(*processSupervisor)
if tc.initiallyStarted {
ps.firstStart.Store(true)
}
@@ -470,7 +573,7 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
},
} {
t.Run(tc.scenario, func(t *testing.T) {
ps := NewProcessSupervisor(slog.New(slog.DiscardHandler), new(ProcessMock), 0, 0, 1, 0).(*processSupervisor)
ps := NewProcessSupervisor(slog.New(slog.DiscardHandler), "test", new(ProcessMock), 0, 0, 1, 0).(*processSupervisor)
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
if tc.ctxDone {
@@ -504,7 +607,7 @@ func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
return true
},
}
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, 0).(*processSupervisor)
// Simulating a lock.
ps.semaphore <- struct{}{}
@@ -565,7 +668,7 @@ func TestProcessSupervisor_QueueSizeCAS(t *testing.T) {
maxQueueSize := int64(50)
// maxConcurrency=1 so all goroutines block on the semaphore, exercising queue logic.
ps := NewProcessSupervisor(logger, process, 0, maxQueueSize, 1, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, "test", process, 0, maxQueueSize, 1, 0).(*processSupervisor)
// Simulating a lock so that all goroutines queue up.
ps.semaphore <- struct{}{}
@@ -619,7 +722,7 @@ func TestProcessSupervisor_QueueSizeIncludesActiveTasks(t *testing.T) {
}
// maxQueueSize=1, maxConcurrency=1: only one request at a time.
ps := NewProcessSupervisor(logger, process, 0, 1, 1, 0)
ps := NewProcessSupervisor(logger, "test", process, 0, 1, 1, 0)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -708,7 +811,7 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, 0).(*processSupervisor)
ps.restartsCounter.Store(tc.initialRestartsCount)
for i := 0; i < tc.restartAttempts; i++ {
@@ -741,7 +844,7 @@ func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
}
maxConcurrency := int64(3)
ps := NewProcessSupervisor(logger, process, 0, 0, maxConcurrency, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, "test", process, 0, 0, maxConcurrency, 0).(*processSupervisor)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -803,7 +906,7 @@ func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) {
}
maxConcurrency := int64(3)
ps := NewProcessSupervisor(logger, process, 3, 0, maxConcurrency, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, "test", process, 3, 0, maxConcurrency, 0).(*processSupervisor)
ps.firstStart.Store(true)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -860,7 +963,7 @@ func TestProcessSupervisor_IdleShutdown(t *testing.T) {
}
idleTimeout := 50 * time.Millisecond
ps := NewProcessSupervisor(logger, process, 0, 0, 1, idleTimeout).(*processSupervisor)
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, idleTimeout).(*processSupervisor)
ctx := context.Background()
err := ps.Run(ctx, logger, func() error {
@@ -898,6 +1001,53 @@ func TestProcessSupervisor_IdleShutdown(t *testing.T) {
}
}
func TestProcessSupervisor_RetryAfterFailedFirstStart(t *testing.T) {
// Regression test for https://github.com/gotenberg/gotenberg/issues/1538:
// a failed first launch must not poison the supervisor; the next request
// must retry Launch() instead of returning the cached error forever.
logger := slog.New(slog.DiscardHandler)
var startCalls atomic.Int64
process := &ProcessMock{
StartMock: func(logger *slog.Logger) error {
if startCalls.Add(1) == 1 {
return errors.New("first start failed")
}
return nil
},
StopMock: func(logger *slog.Logger) error {
return nil
},
HealthyMock: func(logger *slog.Logger) bool {
return true
},
}
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, 0).(*processSupervisor)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := ps.Run(ctx, logger, func() error { return nil })
if err == nil {
t.Fatal("expected first Run to fail because Launch failed")
}
if ps.firstStart.Load() {
t.Fatal("firstStart must remain false after a failed Launch")
}
err = ps.Run(ctx, logger, func() error { return nil })
if err != nil {
t.Fatalf("expected second Run to succeed after the supervisor retries Launch, got: %v", err)
}
if !ps.firstStart.Load() {
t.Fatal("expected firstStart to be set after the second Launch succeeds")
}
if got := startCalls.Load(); got != 2 {
t.Fatalf("expected exactly 2 Start calls, got %d", got)
}
}
func TestProcessSupervisor_IdleShutdownSkippedWhenActive(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
@@ -919,7 +1069,7 @@ func TestProcessSupervisor_IdleShutdownSkippedWhenActive(t *testing.T) {
}
idleTimeout := 50 * time.Millisecond
ps := NewProcessSupervisor(logger, process, 0, 0, 1, idleTimeout)
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, idleTimeout)
ctx := context.Background()
go func() {
@@ -941,3 +1091,70 @@ func TestProcessSupervisor_IdleShutdownSkippedWhenActive(t *testing.T) {
close(taskDone)
}
func TestProcessSupervisor_ConversionsSinceRestart(t *testing.T) {
process := &ProcessMock{
StartMock: func(*slog.Logger) error { return nil },
StopMock: func(*slog.Logger) error { return nil },
HealthyMock: func(*slog.Logger) bool { return true },
}
s := NewProcessSupervisor(slog.New(slog.DiscardHandler), "test", process, 0, 0, 1, 0).(*processSupervisor)
if got := s.ConversionsSinceRestart(); got != 0 {
t.Errorf("expected 0 conversions initially, got %d", got)
}
s.reqCounter.Store(7)
if got := s.ConversionsSinceRestart(); got != 7 {
t.Errorf("expected 7 conversions, got %d", got)
}
if err := s.restart(); err != nil {
t.Fatalf("restart: %v", err)
}
if got := s.ConversionsSinceRestart(); got != 0 {
t.Errorf("expected 0 conversions after restart, got %d", got)
}
}
func TestProcessSupervisor_RunEmitsSubSpans(t *testing.T) {
recorder := newTestSpanRecorder(t)
process := &ProcessMock{
StartMock: func(*slog.Logger) error { return nil },
StopMock: func(*slog.Logger) error { return nil },
HealthyMock: func(*slog.Logger) bool { return true },
}
s := NewProcessSupervisor(slog.New(slog.DiscardHandler), "chromium", process, 0, 0, 1, 0)
err := s.Run(context.Background(), slog.New(slog.DiscardHandler), func() error { return nil })
if err != nil {
t.Fatalf("run: %v", err)
}
var queueWaits, processStarts int
var startReason string
for _, span := range recorder.Ended() {
switch span.Name() {
case "chromium.queue.wait":
queueWaits++
case "chromium.process.start":
processStarts++
for _, kv := range span.Attributes() {
if string(kv.Key) == "gotenberg.process.start.reason" {
startReason = kv.Value.AsString()
}
}
}
}
if queueWaits != 1 {
t.Errorf("expected 1 chromium.queue.wait span, got %d", queueWaits)
}
if processStarts != 1 {
t.Errorf("expected 1 chromium.process.start span (first start), got %d", processStarts)
}
if startReason != "first_start" {
t.Errorf("expected process start reason first_start, got %q", startReason)
}
}

View File

@@ -29,6 +29,11 @@ const (
DebugLoggingLevel = "debug"
)
const (
LowerLevelCase = "lower"
UpperLevelCase = "upper"
)
// TelemetryConfig gathers the configuration data for Gotenberg's telemetry.
type TelemetryConfig struct {
ServiceName string
@@ -38,6 +43,7 @@ type TelemetryConfig struct {
LogFieldsPrefix string
LogStdFormat string
LogStdEnableGcpFields bool
LogStdLevelCase string
}
func (cfg TelemetryConfig) slogLevel() slog.Level {
@@ -85,6 +91,16 @@ func (cfg TelemetryConfig) Validate() error {
)
}
switch cfg.LogStdLevelCase {
case LowerLevelCase, UpperLevelCase:
break
default:
err = errors.Join(
err,
fmt.Errorf("standard log level case must be either %s or %s", LowerLevelCase, UpperLevelCase),
)
}
return err
}
@@ -92,7 +108,7 @@ func (cfg TelemetryConfig) Validate() error {
func StartTelemetry(cfg TelemetryConfig) (shutdown func(context.Context) error, err error) {
var handlers []slog.Handler
stdHandler, err := log.NewStdHandler(cfg.slogLevel(), cfg.LogStdFormat, cfg.LogFieldsPrefix, cfg.LogStdEnableGcpFields)
stdHandler, err := log.NewStdHandler(cfg.slogLevel(), cfg.LogStdFormat, cfg.LogFieldsPrefix, cfg.LogStdEnableGcpFields, cfg.LogStdLevelCase)
if err != nil {
return nil, fmt.Errorf("get standard logger handler: %w", err)
}

View File

@@ -57,10 +57,13 @@ type Api struct {
}
type downloadFromConfig struct {
allowList []*regexp2.Regexp
denyList []*regexp2.Regexp
maxRetry int
disable bool
allowList []*regexp2.Regexp
denyList []*regexp2.Regexp
denyPrivateIPs bool
denyPublicIPs bool
enableEnvironmentProxy bool
maxRetry int
disable bool
}
// Router is a module interface that adds routes to the [Api].
@@ -197,6 +200,9 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables")
fs.StringSlice("api-download-from-allow-list", []string{}, "Set the allowed URLs for the download from feature using regular expressions - supports multiple values")
fs.StringSlice("api-download-from-deny-list", []string{}, "Set the denied URLs for the download from feature using regular expressions - supports multiple values")
fs.Bool("api-download-from-deny-private-ips", false, "Reject downloadFrom URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted downloadFrom sources to mitigate SSRF against internal services")
fs.Bool("api-download-from-deny-public-ips", false, "Reject downloadFrom URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent downloads from reaching the public internet")
fs.Bool("api-download-from-enable-environment-proxy", false, "Route downloadFrom fetches through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials")
fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature")
fs.Bool("api-disable-download-from", false, "Disable the download from feature")
fs.Bool("api-disable-health-check-route-telemetry", true, "Disable telemetry for health check route")
@@ -235,10 +241,13 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
a.rootPath = flags.MustString("api-root-path")
a.correlationIdHeader = flags.MustDeprecatedString("api-trace-header", "api-correlation-id-header")
a.downloadFromCfg = downloadFromConfig{
allowList: flags.MustRegexpSlice("api-download-from-allow-list"),
denyList: flags.MustRegexpSlice("api-download-from-deny-list"),
maxRetry: flags.MustInt("api-download-from-max-retry"),
disable: flags.MustBool("api-disable-download-from"),
allowList: flags.MustRegexpSlice("api-download-from-allow-list"),
denyList: flags.MustRegexpSlice("api-download-from-deny-list"),
denyPrivateIPs: flags.MustBool("api-download-from-deny-private-ips"),
denyPublicIPs: flags.MustBool("api-download-from-deny-public-ips"),
enableEnvironmentProxy: flags.MustBool("api-download-from-enable-environment-proxy"),
maxRetry: flags.MustInt("api-download-from-max-retry"),
disable: flags.MustBool("api-disable-download-from"),
}
a.disableHealthCheckRouteTelemetry = flags.MustDeprecatedBool("api-disable-health-check-logging", "api-disable-health-check-route-telemetry")
a.disableRootRouteTelemetry = flags.MustBool("api-disable-root-route-telemetry")
@@ -371,6 +380,13 @@ func (a *Api) Validate() error {
err = errors.Join(err, errors.New("IP must be a valid IP address"))
}
if a.downloadFromCfg.enableEnvironmentProxy {
proxyErr := gotenberg.ValidateEnvironmentProxyVariables()
if proxyErr != nil {
err = errors.Join(err, fmt.Errorf("--api-download-from-enable-environment-proxy is set: %w", proxyErr))
}
}
if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") {
err = errors.Join(err,
errors.New("both TLS certificate and key files must be set"),

View File

@@ -111,7 +111,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
if bodyLimit != 0 && newTotal > bodyLimit {
return WrapError(
fmt.Errorf("body limit reached (> %d)", bodyLimit),
NewSentinelHttpError(http.StatusRequestEntityTooLarge, http.StatusText(http.StatusRequestEntityTooLarge)),
NewSentinelHttpError(http.StatusRequestEntityTooLarge, "The request body exceeds the configured size limit. Increase it with --api-body-limit, or send a smaller request."),
)
}
return nil
@@ -216,6 +216,15 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
)
}
// Each goroutine writes to its own results slot. The main
// goroutine merges into ctx.files, ctx.diskToOriginal, and
// ctx.filesByField after eg.Wait() to avoid concurrent map
// writes.
type downloadFromResult struct {
filename, path, formField string
}
results := make([]downloadFromResult, len(dls))
eg, _ := errgroup.WithContext(ctx)
for i, dl := range dls {
eg.Go(func() error {
@@ -232,7 +241,11 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
)
}
err := gotenberg.FilterDeadline(downloadFromCfg.allowList, downloadFromCfg.denyList, dl.Url, deadline)
ipOpts := []gotenberg.DecideOption{
gotenberg.WithDenyPrivateIPs(downloadFromCfg.denyPrivateIPs),
gotenberg.WithDenyPublicIPs(downloadFromCfg.denyPublicIPs),
}
err := gotenberg.FilterOutboundURL(ctx, dl.Url, downloadFromCfg.allowList, downloadFromCfg.denyList, deadline, ipOpts...)
if err != nil {
return fmt.Errorf("filter URL: %w", err)
}
@@ -268,9 +281,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
}
client := &retryablehttp.Client{
HTTPClient: &http.Client{
Timeout: time.Until(deadline),
},
HTTPClient: gotenberg.NewOutboundHttpClient(time.Until(deadline), downloadFromCfg.allowList, downloadFromCfg.denyList, downloadFromCfg.enableEnvironmentProxy, ipOpts...),
RetryMax: downloadFromCfg.maxRetry,
RetryWaitMin: time.Duration(1) * time.Second,
RetryWaitMax: time.Until(deadline),
@@ -346,10 +357,13 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
)
}
// Avoid directory traversal and make sure filename characters are
// normalized.
// Strip path separators (including backslashes) and control
// characters, then NFC-normalize. Defends against directory
// traversal in the on-disk name and Windows-side Zip Slip
// when the original filename is later embedded in an output
// zip entry.
// See: https://github.com/gotenberg/gotenberg/issues/662.
filename = norm.NFC.String(filepath.Base(filename))
filename = sanitizeFilename(filename)
// Use a UUID-based name on disk to avoid filesystem
// NAME_MAX limits with long filenames.
@@ -387,18 +401,16 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
dlSpan.SetStatus(codes.Ok, "")
dlSpan.End()
ctx.files[filename] = path
ctx.diskToOriginal[path] = filename
// Route the downloaded file to the appropriate field bucket.
var formField string
switch {
case dl.Field == "embedded" || dl.Embedded:
ctx.filesByField[EmbedsFormField] = append(ctx.filesByField[EmbedsFormField], path)
formField = EmbedsFormField
case dl.Field == "watermark":
ctx.filesByField[WatermarkFormField] = append(ctx.filesByField[WatermarkFormField], path)
formField = WatermarkFormField
case dl.Field == "stamp":
ctx.filesByField[StampFormField] = append(ctx.filesByField[StampFormField], path)
formField = StampFormField
}
results[i] = downloadFromResult{filename: filename, path: path, formField: formField}
return nil
})
@@ -408,6 +420,14 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
if err != nil {
return ctx, cancel, err
}
for _, r := range results {
ctx.files[r.filename] = r.path
ctx.diskToOriginal[r.path] = r.filename
if r.formField != "" {
ctx.filesByField[r.formField] = append(ctx.filesByField[r.formField], r.path)
}
}
}
copyToDisk := func(fh *multipart.FileHeader) error {
@@ -426,10 +446,12 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
// This will ensure we do not exceed the body limit.
reader := &trackingReader{R: in, AddReadBytes: addReadBytes}
// Avoid directory traversal and make sure filename characters are
// normalized.
// Strip path separators (including backslashes) and control
// characters, then NFC-normalize. Defends against directory
// traversal in the on-disk name and Windows-side Zip Slip when the
// original filename is later embedded in an output zip entry.
// See: https://github.com/gotenberg/gotenberg/issues/662.
filename := norm.NFC.String(filepath.Base(fh.Filename))
filename := sanitizeFilename(fh.Filename)
// Use a UUID-based name on disk to avoid filesystem
// NAME_MAX limits with long filenames.
@@ -467,7 +489,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
return ctx, cancel, fmt.Errorf("copy to disk: %w", err)
}
// Track files by field name
filename := norm.NFC.String(filepath.Base(fh.Filename))
filename := sanitizeFilename(fh.Filename)
filePath := ctx.files[filename]
ctx.filesByField[fieldName] = append(ctx.filesByField[fieldName], filePath)
}
@@ -514,6 +536,11 @@ func (ctx *Context) FormData() *FormData {
}
}
// FileCount returns the number of files received in the request.
func (ctx *Context) FileCount() int {
return len(ctx.files)
}
// OriginalFilename returns the original filename associated with a disk path.
// If no mapping exists, it falls back to [filepath.Base].
func (ctx *Context) OriginalFilename(diskPath string) string {
@@ -656,3 +683,21 @@ func (ctx *Context) OutputFilename(outputPath string) string {
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))
}
// sanitizeFilename strips path separators (including backslashes, which
// [filepath.Base] ignores on Linux) and control characters from a
// caller-supplied filename, then NFC-normalizes the result. This prevents a
// Windows-side Zip Slip when an output zip is extracted by a permissive
// extractor that interprets '\' as a path separator.
func sanitizeFilename(name string) string {
if i := strings.LastIndexAny(name, `/\`); i >= 0 {
name = name[i+1:]
}
name = strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, name)
return norm.NFC.String(name)
}

View File

@@ -3,10 +3,13 @@ package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"mime/multipart"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
@@ -69,3 +72,153 @@ func TestNewContext_Cancellation(t *testing.T) {
t.Fatal("expected context to be cancelled after request context cancellation, but it timed out")
}
}
// Concurrent downloadFrom entries must not race on the shared maps
// (ctx.files, ctx.diskToOriginal, ctx.filesByField). Run under -race
// to catch the data race; without -race a sufficient number of entries
// still surfaces "fatal error: concurrent map writes".
func TestNewContext_DownloadFromConcurrentMapWrites(t *testing.T) {
const downloads = 64
var ready sync.WaitGroup
ready.Add(downloads)
release := make(chan struct{})
var releaseOnce sync.Once
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ready.Done()
go func() {
ready.Wait()
releaseOnce.Do(func() { close(release) })
}()
<-release
filename := fmt.Sprintf("download-%s.txt", r.URL.Query().Get("i"))
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
_, _ = w.Write([]byte("downloaded"))
}))
defer server.Close()
dls := make([]downloadFrom, downloads)
for i := range dls {
dls[i] = downloadFrom{
Url: fmt.Sprintf("%s/file?i=%d", server.URL, i),
Field: "embedded",
}
}
payload, err := json.Marshal(dls)
if err != nil {
t.Fatalf("marshal downloadFrom payload: %v", err)
}
body := new(bytes.Buffer)
writer := multipart.NewWriter(body)
err = writer.WriteField("downloadFrom", string(payload))
if err != nil {
t.Fatalf("write downloadFrom field: %v", err)
}
err = writer.Close()
if err != nil {
t.Fatalf("close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/forms/libreoffice/convert", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
echoCtx := echo.New().NewContext(req, httptest.NewRecorder())
logger := slog.New(slog.DiscardHandler)
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
downloadFromCfg := downloadFromConfig{
maxRetry: 0,
}
ctx, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromCfg)
if err != nil {
t.Fatalf("newContext returned error: %v", err)
}
defer cancel()
if got := len(ctx.files); got != downloads {
t.Fatalf("downloaded files = %d, want %d", got, downloads)
}
if got := len(ctx.diskToOriginal); got != downloads {
t.Fatalf("diskToOriginal entries = %d, want %d", got, downloads)
}
if got := len(ctx.filesByField[EmbedsFormField]); got != downloads {
t.Fatalf("filesByField[%q] entries = %d, want %d", EmbedsFormField, got, downloads)
}
}
func TestSanitizeFilename(t *testing.T) {
for _, tc := range []struct {
scenario string
input string
expect string
}{
{
scenario: "plain filename is unchanged",
input: "report.pdf",
expect: "report.pdf",
},
{
scenario: "POSIX traversal is stripped",
input: "../../etc/passwd",
expect: "passwd",
},
{
scenario: "Windows traversal with backslashes is stripped",
input: `..\..\..\..\Windows\System32\evil.pdf`,
expect: "evil.pdf",
},
{
scenario: "mixed separators take the last segment",
input: `foo/bar\baz.pdf`,
expect: "baz.pdf",
},
{
scenario: "control characters are dropped",
input: "evil\x00\x07\x1f\x7f.pdf",
expect: "evil.pdf",
},
{
scenario: "NFC normalization collapses decomposed sequences",
// "e" + combining acute accent -> precomposed "é".
input: "café.pdf",
expect: "café.pdf",
},
{
scenario: "trailing backslash yields empty name",
input: `foo\`,
expect: "",
},
{
scenario: "empty input yields empty name",
input: "",
expect: "",
},
} {
t.Run(tc.scenario, func(t *testing.T) {
got := sanitizeFilename(tc.input)
if got != tc.expect {
t.Errorf("sanitizeFilename(%q) = %q, want %q", tc.input, got, tc.expect)
}
})
}
}
func TestContext_FileCount(t *testing.T) {
ctx := &Context{}
if got := ctx.FileCount(); got != 0 {
t.Errorf("expected 0 files, got %d", got)
}
ctx.files = map[string]string{
"index.html": "/work/index.html",
"header.html": "/work/header.html",
"styles.css": "/work/styles.css",
}
if got := ctx.FileCount(); got != 3 {
t.Errorf("expected 3 files, got %d", got)
}
}

View File

@@ -1,6 +1,7 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"math"
@@ -25,6 +26,10 @@ const (
// StampFormField represents the form field name for the stamp file.
StampFormField string = "stamp"
// FacturXXmlFormField represents the form field name for the Factur-X CII
// invoice XML file.
FacturXXmlFormField string = "facturxXml"
)
// FormData is a helper for validating and hydrating values from a
@@ -391,6 +396,38 @@ func (form *FormData) Embeds(target *[]string) *FormData {
return form
}
// EmbedsMetadata parses the "embedsMetadata" form field (a JSON string) into
// a map keyed by filename. Each value is a map of property names to values
// (e.g., "mimeType" and "relationship").
//
// var metadata map[string]map[string]string
//
// ctx.FormData().EmbedsMetadata(&metadata)
func (form *FormData) EmbedsMetadata(target *map[string]map[string]string) *FormData {
if form.errors != nil {
return form
}
val, ok := form.values["embedsMetadata"]
if !ok || len(val) == 0 || val[0] == "" {
return form
}
raw := val[0]
parsed := make(map[string]map[string]string)
err := json.Unmarshal([]byte(raw), &parsed)
if err != nil {
form.append(
fmt.Errorf("form field 'embedsMetadata' is invalid: %w", err),
)
return form
}
*target = parsed
return form
}
// MandatoryPaths binds the absolute paths of form data files, according to a
// list of file extensions, to a string slice variable. It populates an error
// if there is no file for given file extensions.
@@ -442,13 +479,28 @@ func (form *FormData) Stamp(target *string) *FormData {
return form
}
// FacturXXml binds the absolute path of the uploaded Factur-X CII invoice
// XML. Only a file uploaded with the "facturxXml" field name is included.
func (form *FormData) FacturXXml(target *string) *FormData {
if form.errors != nil {
return form
}
if paths, ok := form.filesByField[FacturXXmlFormField]; ok && len(paths) > 0 {
*target = paths[0]
}
return form
}
// paths bind the absolute paths of form data files, according to a list of
// file extensions, to a string slice variable.
// embeds, watermark, and stamp files are excluded.
// embeds, watermark, stamp, and facturxXml files are excluded.
func (form *FormData) paths(extensions []string, target *[]string) *FormData {
embeds, ok := form.filesByField[EmbedsFormField]
watermarks, wmOk := form.filesByField[WatermarkFormField]
stamps, stOk := form.filesByField[StampFormField]
facturxXmls, fxOk := form.filesByField[FacturXXmlFormField]
// Collect (originalFilename, diskPath) pairs so that we can sort by
// original filename rather than by UUID-based disk name.
@@ -472,6 +524,10 @@ func (form *FormData) paths(extensions []string, target *[]string) *FormData {
continue
}
if fxOk && slices.Contains(facturxXmls, path) {
continue
}
for _, ext := range extensions {
// See https://github.com/gotenberg/gotenberg/issues/228.
if strings.ToLower(filepath.Ext(filename)) == ext {

View File

@@ -1783,3 +1783,57 @@ func TestFormData_Embeds(t *testing.T) {
t.Errorf("expected %v but got %v", expected, actual)
}
}
func TestFormData_FacturXXml(t *testing.T) {
for _, tc := range []struct {
scenario string
form *FormData
expect string
}{
{
scenario: "no facturxXml file",
form: &FormData{},
expect: "",
},
{
scenario: "facturxXml file present",
form: &FormData{
filesByField: map[string][]string{
FacturXXmlFormField: {"/tmp/abc/12345.xml"},
},
},
expect: "/tmp/abc/12345.xml",
},
} {
t.Run(tc.scenario, func(t *testing.T) {
var actual string
tc.form.FacturXXml(&actual)
if actual != tc.expect {
t.Errorf("expected %q but got %q", tc.expect, actual)
}
})
}
}
// TestFormData_paths_excludesFacturXXml verifies that an uploaded facturxXml is
// never picked up as an input document by paths().
func TestFormData_paths_excludesFacturXXml(t *testing.T) {
form := &FormData{
files: map[string]string{
"document.xml": "/tmp/abc/document.xml",
"factur-x.xml": "/tmp/abc/invoice.xml",
},
filesByField: map[string][]string{
FacturXXmlFormField: {"/tmp/abc/invoice.xml"},
},
}
var paths []string
form.paths([]string{".xml"}, &paths)
if len(paths) != 1 || paths[0] != "/tmp/abc/document.xml" {
t.Errorf("expected only the non-Factur-X .xml document, got %+v", paths)
}
}

View File

@@ -7,7 +7,6 @@ import (
"fmt"
"log/slog"
"net/http"
"path/filepath"
"strings"
"time"
@@ -43,7 +42,7 @@ func ParseError(err error) (int, string) {
}
if errors.Is(err, context.DeadlineExceeded) {
return http.StatusServiceUnavailable, http.StatusText(http.StatusServiceUnavailable)
return http.StatusServiceUnavailable, "The request exceeded the time limit. Increase it with --api-timeout, or reduce the workload."
}
if errors.Is(err, gotenberg.ErrFiltered) {
@@ -51,27 +50,27 @@ func ParseError(err error) (int, string) {
}
if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
return http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests)
return http.StatusTooManyRequests, "The request queue is full. Retry shortly, or raise the limit with --chromium-max-queue-size or --libreoffice-max-queue-size."
}
if errors.Is(err, gotenberg.ErrPdfSplitModeNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues"
return http.StatusBadRequest, "The requested split mode is not supported, or no PDF engine could process it. Valid modes: 'intervals', 'pages'."
}
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues"
return http.StatusBadRequest, "The requested PDF format is not supported, or no PDF engine could apply it. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA."
}
if errors.Is(err, gotenberg.ErrPdfEngineMetadataValueNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested metadata, while others may have failed to convert due to different issues"
return http.StatusBadRequest, "The requested metadata could not be written; ensure values are valid and free of control characters."
}
if errors.Is(err, gotenberg.ErrPdfStampSourceNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested stamp source type, while others may have failed due to different issues"
return http.StatusBadRequest, "The requested stamp source is not supported, or no PDF engine could process it. Valid sources: 'text', 'image', 'pdf'."
}
if errors.Is(err, gotenberg.ErrPdfRotateAngleNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested rotation angle, while others may have failed due to different issues"
return http.StatusBadRequest, "The requested rotation angle is not supported. Valid angles: 90, 180, 270."
}
if invalidArgsError, ok := errors.AsType[*gotenberg.PdfEngineInvalidArgsError](err); ok {
@@ -150,9 +149,16 @@ func outputFilenameMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
filename := c.Request().Header.Get("Gotenberg-Output-Filename")
// Keep only the last path segment, so that a caller cannot name an
// output file after a path.
// See https://github.com/gotenberg/gotenberg/issues/1227.
//
// [filepath.Base] alone is not enough: on Linux it does not treat a
// backslash as a separator, and this value reaches archive entry
// names. Use the same sanitizer as the other caller-supplied
// filenames.
if filename != "" {
filename = filepath.Base(filename)
filename = sanitizeFilename(filename)
}
c.Set("outputFilename", filename)
// Call the next middleware in the chain.
@@ -337,7 +343,10 @@ func basicAuthMiddleware(username, password string) echo.MiddlewareFunc {
func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
logger := c.Get("logger").(*slog.Logger)
logger, _ := c.Get("logger").(*slog.Logger)
if logger == nil {
return errors.New("no logger in context (possible pool reuse)")
}
// We create a context with a timeout so that underlying processes are
// able to stop early and correctly handle a timeout scenario.
@@ -395,7 +404,14 @@ func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimi
func hardTimeoutMiddleware(hardTimeout time.Duration) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
logger := c.Get("logger").(*slog.Logger)
// Guard the type assertion so a pooled [echo.Context] whose
// store has been recycled under us does not crash the process.
// See the webhook async handler for the race this protects
// against.
logger, _ := c.Get("logger").(*slog.Logger)
if logger == nil {
return errors.New("no logger in context (possible pool reuse)")
}
// Define a hard timeout if the route handler fails to timeout as
// expected.

View File

@@ -0,0 +1,86 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/labstack/echo/v4"
)
// TestOutputFilenameMiddleware pins the sanitizing of the
// "Gotenberg-Output-Filename" header. The value reaches archive entry names and
// a Content-Disposition header, so a path separator must never survive it.
// See https://github.com/gotenberg/gotenberg/issues/1227 and
// GHSA-hwc4-gmrw-5222.
func TestOutputFilenameMiddleware(t *testing.T) {
for _, tc := range []struct {
name string
header string
want string
}{
{"no header", "", ""},
{"plain filename", "foo", "foo"},
{"POSIX path", "/tmp/foo", "foo"},
{"POSIX traversal", "../../../etc/passwd", "passwd"},
{"Windows traversal", `..\..\..\..\Windows\System32\evil`, "evil"},
{"rooted Windows path", `C:\Windows\Temp\evil`, "evil"},
{"mixed separators", `a/b\c`, "c"},
{"trailing separator", "/tmp/", ""},
{"bare dot dot", "..", ".."},
{"control characters", "fo\x01o\x7f", "foo"},
} {
t.Run(tc.name, func(t *testing.T) {
handler := outputFilenameMiddleware()(func(c echo.Context) error { return nil })
req := httptest.NewRequest(http.MethodPost, "/", nil)
if tc.header != "" {
req.Header.Set("Gotenberg-Output-Filename", tc.header)
}
c := echo.New().NewContext(req, httptest.NewRecorder())
err := handler(c)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got, ok := c.Get("outputFilename").(string)
if !ok {
t.Fatal("outputFilename is not set as a string")
}
if got != tc.want {
t.Errorf("outputFilename = %q, want %q", got, tc.want)
}
})
}
}
func TestHardTimeoutMiddleware_MissingLoggerReturnsErrorInsteadOfPanicking(t *testing.T) {
mw := hardTimeoutMiddleware(100 * time.Millisecond)
handler := mw(func(c echo.Context) error { return nil })
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// c has no "logger" key, mimicking a pooled context whose store was
// recycled under a concurrently running webhook goroutine. The
// middleware must surface an error instead of panicking on the
// unchecked type assertion the pre-fix code relied on.
defer func() {
if r := recover(); r != nil {
t.Fatalf("hardTimeoutMiddleware panicked: %v", r)
}
}()
err := handler(c)
if err == nil {
t.Fatal("expected an error for missing logger, got nil")
}
if !strings.Contains(err.Error(), "logger") {
t.Fatalf("error = %q, want a message mentioning logger", err)
}
}

View File

@@ -0,0 +1,87 @@
package chromium
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"go.opentelemetry.io/otel/attribute"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
)
func TestPrintToPdfAttrs(t *testing.T) {
options := DefaultPdfOptions()
options.Landscape = true
options.PageRanges = "1-5"
options.HeaderTemplate = "<div>secret header</div>"
// FooterTemplate left at default, so has_footer must be false.
got := map[string]attribute.Value{}
for _, kv := range printToPdfAttrs(options) {
got[string(kv.Key)] = kv.Value
if s := kv.Value.AsString(); strings.Contains(s, "secret") || s == "1-5" {
t.Errorf("attribute %s leaked a raw value: %q", kv.Key, s)
}
}
if !got["gotenberg.chromium.print.landscape"].AsBool() {
t.Error("expected landscape=true")
}
if !got["gotenberg.chromium.print.has_page_ranges"].AsBool() {
t.Error("expected has_page_ranges=true")
}
if !got["gotenberg.chromium.print.has_header"].AsBool() {
t.Error("expected has_header=true")
}
if got["gotenberg.chromium.print.has_footer"].AsBool() {
t.Error("expected has_footer=false")
}
}
func TestConversionInputAttrs(t *testing.T) {
tmp := filepath.Join(t.TempDir(), "index.html")
content := []byte("<html></html>")
if err := os.WriteFile(tmp, content, 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
t.Run("file URL with non-api context", func(t *testing.T) {
got := map[string]int64{}
for _, kv := range conversionInputAttrs(context.Background(), "file://"+tmp) {
got[string(kv.Key)] = kv.Value.AsInt64()
}
if _, ok := got["gotenberg.conversion.input.files.count"]; ok {
t.Error("did not expect files.count for a non-api context")
}
if got["gotenberg.conversion.input.html.bytes"] != int64(len(content)) {
t.Errorf("expected html.bytes=%d, got %d", len(content), got["gotenberg.conversion.input.html.bytes"])
}
})
t.Run("remote URL yields no html.bytes", func(t *testing.T) {
for _, kv := range conversionInputAttrs(context.Background(), "https://example.com") {
if string(kv.Key) == "gotenberg.conversion.input.html.bytes" {
t.Error("did not expect html.bytes for a remote URL")
}
}
})
t.Run("api context yields files.count", func(t *testing.T) {
var found bool
for _, kv := range conversionInputAttrs(&api.Context{}, "https://example.com") {
if string(kv.Key) == "gotenberg.conversion.input.files.count" {
found = true
if kv.Value.AsInt64() != 0 {
t.Errorf("expected files.count=0, got %d", kv.Value.AsInt64())
}
}
}
if !found {
t.Error("expected files.count for an api context")
}
})
}

View File

@@ -25,8 +25,8 @@ import (
type browser interface {
gotenberg.Process
pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error
screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error
pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions, aggregate *networkAggregate) error
screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions, aggregate *networkAggregate) error
}
type browserArguments struct {
@@ -38,12 +38,15 @@ type browserArguments struct {
allowFileAccessFromFiles bool
hostResolverRules string
proxyServer string
enableEnvironmentProxy bool
wsUrlReadTimeout time.Duration
hyphenDataDirPath string
// Tasks specific.
allowList []*regexp2.Regexp
denyList []*regexp2.Regexp
denyPrivateIPs bool
denyPublicIPs bool
clearCache bool
clearCookies bool
disableJavaScript bool
@@ -56,16 +59,26 @@ type chromiumBrowser struct {
userProfileDirPath string
ctxMu sync.RWMutex
isStarted atomic.Bool
// startMu serializes Start calls. The supervisor's runWithDeadline
// abandons a Start goroutine when the request deadline expires while
// Chromium's startup handshake is still hanging; the abandoned goroutine
// keeps running, holding the resources it acquired (the pinning proxy).
// Serializing here prevents a second, overlapping Start from colliding
// with the in-flight one on the shared pinning proxy.
// See https://github.com/gotenberg/gotenberg/issues/1599.
startMu sync.Mutex
arguments browserArguments
fs *gotenberg.FileSystem
arguments browserArguments
fs *gotenberg.FileSystem
pinningProxy *pinningProxy
}
func newChromiumBrowser(arguments browserArguments) browser {
b := &chromiumBrowser{
initialCtx: context.Background(),
arguments: arguments,
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
initialCtx: context.Background(),
arguments: arguments,
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
pinningProxy: newPinningProxy(arguments.allowList, arguments.denyList, arguments.denyPrivateIPs, arguments.denyPublicIPs, arguments.enableEnvironmentProxy),
}
b.isStarted.Store(false)
@@ -73,6 +86,19 @@ func newChromiumBrowser(arguments browserArguments) browser {
}
func (b *chromiumBrowser) Start(logger *slog.Logger) error {
// Refuse to run while a previous Start is still in flight. That previous
// Start may be a goroutine the supervisor abandoned after the request
// deadline expired while the Chromium startup handshake was hanging; it
// still holds the pinning proxy it started. An abandoned goroutine keeps
// holding startMu until it unwinds (bounded by --chromium-start-timeout),
// so no overlapping Start can collide with it on the shared pinning proxy
// and latch Chromium into a permanent "pinning proxy already started"
// state. See https://github.com/gotenberg/gotenberg/issues/1599.
if !b.startMu.TryLock() {
return errors.New("browser start already in progress")
}
defer b.startMu.Unlock()
if b.isStarted.Load() {
return errors.New("browser is already started")
}
@@ -136,6 +162,25 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
opts = append(opts, chromedp.ProxyServer(b.arguments.proxyServer))
}
// Default: route Chromium through the internal pinning proxy so that
// Chromium never performs its own DNS lookup for the navigation URL
// or any sub-resource. The proxy resolves and validates each URL
// once per request and dials the pinned IP, closing the DNS
// rebinding window between Gotenberg's validation and Chromium's
// connect.
//
// Skip when the operator has configured their own egress proxy or
// custom host-resolver mappings: those deployments take
// responsibility for outbound safety themselves and routing through
// an internal proxy would override their configuration.
if b.arguments.proxyServer == "" && b.arguments.hostResolverRules == "" {
err = b.pinningProxy.Start(logger)
if err != nil {
return fmt.Errorf("start pinning proxy: %w", err)
}
opts = append(opts, chromedp.ProxyServer(b.pinningProxy.URL()))
}
// See https://github.com/gotenberg/gotenberg/issues/524.
opts = append(opts, chromedp.WSURLReadTimeout(b.arguments.wsUrlReadTimeout))
@@ -146,6 +191,15 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
if err != nil {
cancel()
allocatorCancel()
// The pinning proxy started before chromedp; tear it down so a
// supervisor retry can re-bind. Stop is a no-op when the proxy
// was never started (operator-configured --chromium-proxy-server
// or --chromium-host-resolver-rules).
// See https://github.com/gotenberg/gotenberg/issues/1559.
stopErr := b.pinningProxy.Stop(logger)
if stopErr != nil {
logger.ErrorContext(context.Background(), fmt.Sprintf("stop pinning proxy after failed start: %s", stopErr))
}
return fmt.Errorf("run exec allocator: %w", err)
}
@@ -236,6 +290,15 @@ func (b *chromiumBrowser) Stop(logger *slog.Logger) error {
b.userProfileDirPath = ""
b.isStarted.Store(false)
// Stop the pinning proxy after Chromium shutdown so that any
// in-flight requests Chromium issues during teardown complete. The
// Stop call is a no-op when the proxy was not started (operator
// configured --chromium-proxy-server or --chromium-host-resolver-rules).
err := b.pinningProxy.Stop(logger)
if err != nil {
logger.ErrorContext(context.Background(), fmt.Sprintf("stop pinning proxy: %s", err))
}
return nil
}
@@ -273,10 +336,10 @@ func (b *chromiumBrowser) Healthy(logger *slog.Logger) bool {
return true
}
func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions, aggregate *networkAggregate) error {
// Note: no error wrapping because it leaks on errors we want to display to
// the end user.
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
return b.do(ctx, logger, url, options.Options, aggregate, chromedp.Tasks{
network.Enable(),
fetch.Enable(),
runtime.Enable(),
@@ -293,16 +356,16 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, out
waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector),
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
// PDF specific.
printToPdfActionFunc(logger, outputPath, options),
printToPdfActionFunc(ctx, logger, outputPath, options),
// Teardown.
page.Close(),
})
}
func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions, aggregate *networkAggregate) error {
// Note: no error wrapping because it leaks on errors we want to display to
// the end user.
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
return b.do(ctx, logger, url, options.Options, aggregate, chromedp.Tasks{
network.Enable(),
fetch.Enable(),
runtime.Enable(),
@@ -319,14 +382,14 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, u
waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector),
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
// Screenshot specific.
setDeviceMetricsOverride(logger, options.Width, options.Height),
setDeviceMetricsOverride(logger, options.Width, options.Height, options.DeviceScaleFactor),
captureScreenshotActionFunc(logger, outputPath, options),
// Teardown.
page.Close(),
})
}
func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url string, options Options, tasks chromedp.Tasks) error {
func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url string, options Options, aggregate *networkAggregate, tasks chromedp.Tasks) error {
if !b.isStarted.Load() {
return errors.New("browser not started, cannot handle tasks")
}
@@ -336,8 +399,12 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
return errors.New("context has no deadline")
}
// We validate the "main" URL against our allowed / deny lists.
err := gotenberg.FilterDeadline(b.arguments.allowList, b.arguments.denyList, url, deadline)
// We validate the "main" URL against our allowed / deny lists, and
// against the IP-based outbound URL guard. See [gotenberg.FilterOutboundURL].
err := gotenberg.FilterOutboundURL(ctx, url, b.arguments.allowList, b.arguments.denyList, deadline,
gotenberg.WithDenyPrivateIPs(b.arguments.denyPrivateIPs),
gotenberg.WithDenyPublicIPs(b.arguments.denyPublicIPs),
)
if err != nil {
return fmt.Errorf("filter URL: %w", err)
}
@@ -351,6 +418,9 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
taskCtx, taskCancel := chromedp.NewContext(timeoutCtx)
defer taskCancel()
// Accumulate per-conversion network activity for telemetry.
listenForNetworkActivity(taskCtx, aggregate)
// We validate all other requests against our allowed / deny lists.
// If a request does not pass the validation, we make it fail. It also set
// the extra HTTP headers, if any.
@@ -358,6 +428,8 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
listenForEventRequestPaused(taskCtx, logger, eventRequestPausedOptions{
allowList: b.arguments.allowList,
denyList: b.arguments.denyList,
denyPrivateIPs: b.arguments.denyPrivateIPs,
denyPublicIPs: b.arguments.denyPublicIPs,
allowedFilePrefixes: options.AllowedFilePrefixes,
extraHttpHeaders: options.ExtraHttpHeaders,
})

View File

@@ -0,0 +1,39 @@
package chromium
import (
"context"
"log/slog"
"strings"
"testing"
)
// TestChromiumBrowser_Start_rejectsOverlappingStart guards against the latch
// reported in https://github.com/gotenberg/gotenberg/issues/1599. When the
// supervisor abandons a Start goroutine on request-deadline expiry, that
// goroutine keeps running and holds startMu (and the pinning proxy it started)
// until it unwinds. A second Start must be refused rather than proceeding to
// start the pinning proxy a second time.
func TestChromiumBrowser_Start_rejectsOverlappingStart(t *testing.T) {
b := &chromiumBrowser{initialCtx: context.Background()}
// Simulate a Start still in flight.
b.startMu.Lock()
defer b.startMu.Unlock()
err := b.Start(slog.New(slog.DiscardHandler))
if err == nil {
t.Fatal("expected an error when a start is already in progress, got nil")
}
if !strings.Contains(err.Error(), "already in progress") {
t.Fatalf("expected an 'already in progress' error, got %q", err)
}
// The guard must return before touching any startup resource, so no user
// profile directory is created and the browser stays not started.
if b.userProfileDirPath != "" {
t.Fatalf("expected no user profile directory to be created, got %q", b.userProfileDirPath)
}
if b.isStarted.Load() {
t.Fatal("expected the browser to stay not started")
}
}

View File

@@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"strings"
"sync"
"syscall"
"time"
@@ -101,12 +102,17 @@ type Chromium struct {
supervisor gotenberg.ProcessSupervisor
engine gotenberg.PdfEngine
version string
versionOnce sync.Once
reqsCounter metric.Int64Counter
errsCounter metric.Int64Counter
conversionDurationCounter metric.Float64Histogram
queueWaitDurationCounter metric.Float64Histogram
pdfOutputSizeCounter metric.Int64Histogram
imageOutputSizeCounter metric.Int64Histogram
networkRequestsCounter metric.Int64Counter
networkBytesCounter metric.Int64Histogram
}
// Options are the common options for all conversions.
@@ -189,10 +195,13 @@ type Options struct {
// PDFs with transparency.
OmitBackground bool
// AllowedFilePrefixes restricts file:// sub-resource access to only these
// directory prefixes. Applied in listenForEventRequestPaused in addition
// to the global allow/deny lists. Set internally by route handlers, not
// via form data.
// AllowedFilePrefixes restricts file:// sub-resource access to only
// these directory prefixes. Applied in listenForEventRequestPaused in
// addition to the global allow/deny lists. An empty slice
// default-denies every file:// sub-resource, so routes that legitimately
// render local files (HTML, Markdown) must populate this with the
// request working directory while routes that navigate remote URLs
// leave it empty. Set internally by route handlers, not via form data.
AllowedFilePrefixes []string
}
@@ -291,7 +300,8 @@ type PdfOptions struct {
PreferCssPageSize bool
// GenerateDocumentOutline defines whether the document outline should be
// embedded into the PDF.
// embedded into the PDF. Chromium derives the outline from the tagged-PDF
// structure tree, so enabling this implies GenerateTaggedPdf.
GenerateDocumentOutline bool
// GenerateTaggedPdf defines whether to generate tagged (accessible)
@@ -347,18 +357,23 @@ type ScreenshotOptions struct {
// OptimizeForSpeed defines whether to optimize image encoding for speed,
// not for resulting size.
OptimizeForSpeed bool
// DeviceScaleFactor is the ratio of the resolution in physical pixels to
// the resolution in CSS pixels for the current display device.
DeviceScaleFactor float64
}
// DefaultScreenshotOptions returns the default values for ScreenshotOptions.
func DefaultScreenshotOptions() ScreenshotOptions {
return ScreenshotOptions{
Options: DefaultOptions(),
Width: 800,
Height: 600,
Clip: false,
Format: "png",
Quality: 100,
OptimizeForSpeed: false,
Options: DefaultOptions(),
Width: 800,
Height: 600,
Clip: false,
Format: "png",
Quality: 100,
OptimizeForSpeed: false,
DeviceScaleFactor: 1.0,
}
}
@@ -445,8 +460,11 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
fs.Bool("chromium-allow-file-access-from-files", false, "Allow file:// URIs to read other file:// URIs")
fs.String("chromium-host-resolver-rules", "", "Set custom mappings to the host resolver")
fs.String("chromium-proxy-server", "", "Set the outbound proxy server; this switch only affects HTTP and HTTPS requests")
fs.Bool("chromium-enable-environment-proxy", false, "Route Chromium's outbound requests through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials. Use this instead of --chromium-proxy-server for authenticated proxies, and leave --chromium-proxy-server and --chromium-host-resolver-rules unset")
fs.StringSlice("chromium-allow-list", []string{}, "Set the allowed URLs for Chromium using regular expressions - supports multiple values")
fs.StringSlice("chromium-deny-list", []string{`^file:(?!//\/tmp/).*`}, "Set the denied URLs for Chromium using regular expressions - supports multiple values")
fs.Bool("chromium-deny-private-ips", false, "Reject URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted form input to mitigate SSRF against internal services")
fs.Bool("chromium-deny-public-ips", false, "Reject URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent outbound traffic from leaving a private network")
fs.Bool("chromium-clear-cache", false, "Clear Chromium cache between each conversion")
fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion")
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
@@ -474,12 +492,12 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
binPath, ok := os.LookupEnv("CHROMIUM_BIN_PATH")
if !ok {
return errors.New("CHROMIUM_BIN_PATH environment variable is not set")
return errors.New("CHROMIUM_BIN_PATH environment variable is not set; set it to the absolute path of the Chromium or Chrome binary")
}
hyphenDataDirPath, ok := os.LookupEnv("CHROMIUM_HYPHEN_DATA_DIR_PATH")
if !ok {
return errors.New("CHROMIUM_HYPHEN_DATA_DIR_PATH environment variable is not set")
return errors.New("CHROMIUM_HYPHEN_DATA_DIR_PATH environment variable is not set; set it to the absolute path of the Chromium hyphenation data directory (it ships in the Gotenberg image)")
}
mod.args = browserArguments{
@@ -490,11 +508,14 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
allowFileAccessFromFiles: flags.MustBool("chromium-allow-file-access-from-files"),
hostResolverRules: flags.MustString("chromium-host-resolver-rules"),
proxyServer: flags.MustString("chromium-proxy-server"),
enableEnvironmentProxy: flags.MustBool("chromium-enable-environment-proxy"),
wsUrlReadTimeout: flags.MustDuration("chromium-start-timeout"),
hyphenDataDirPath: hyphenDataDirPath,
allowList: flags.MustRegexpSlice("chromium-allow-list"),
denyList: flags.MustRegexpSlice("chromium-deny-list"),
denyPrivateIPs: flags.MustBool("chromium-deny-private-ips"),
denyPublicIPs: flags.MustBool("chromium-deny-public-ips"),
clearCache: flags.MustBool("chromium-clear-cache"),
clearCookies: flags.MustBool("chromium-clear-cookies"),
disableJavaScript: flags.MustBool("chromium-disable-javascript"),
@@ -505,7 +526,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.maxConcurrency, flags.MustDuration("chromium-idle-shutdown-timeout"))
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, "chromium", mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"), mod.maxConcurrency, flags.MustDuration("chromium-idle-shutdown-timeout"))
// PDF Engine.
provider, err := ctx.Module(new(gotenberg.PdfEngineProvider))
@@ -621,6 +642,24 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
return fmt.Errorf("create chromium.image.output.size histogram: %w", err)
}
mod.networkRequestsCounter, err = meter.Int64Counter(
"chromium.network.requests.total",
metric.WithDescription("Total number of network requests made during Chromium conversions"),
metric.WithUnit("{request}"),
)
if err != nil {
return fmt.Errorf("create chromium.network.requests.total counter: %w", err)
}
mod.networkBytesCounter, err = meter.Int64Histogram(
"chromium.network.bytes",
metric.WithDescription("Bytes fetched over the network during a Chromium conversion"),
metric.WithUnit("By"),
)
if err != nil {
return fmt.Errorf("create chromium.network.bytes histogram: %w", err)
}
return nil
}
@@ -630,14 +669,21 @@ func (mod *Chromium) Validate() error {
return fmt.Errorf("chromium-max-concurrency must be between 1 and 6, got %d", mod.maxConcurrency)
}
if mod.args.enableEnvironmentProxy {
proxyErr := gotenberg.ValidateEnvironmentProxyVariables()
if proxyErr != nil {
return fmt.Errorf("--chromium-enable-environment-proxy is set: %w", proxyErr)
}
}
_, err := os.Stat(mod.args.binPath)
if os.IsNotExist(err) {
return fmt.Errorf("chromium binary path does not exist: %w", err)
return fmt.Errorf("Chromium binary does not exist at %q; check the CHROMIUM_BIN_PATH environment variable: %w", mod.args.binPath, err)
}
_, err = os.Stat(mod.args.hyphenDataDirPath)
if os.IsNotExist(err) {
return fmt.Errorf("chromium hyphen-data directory path does not exist: %w", err)
return fmt.Errorf("Chromium hyphenation data directory does not exist at %q; check the CHROMIUM_HYPHEN_DATA_DIR_PATH environment variable (it ships in the Gotenberg image): %w", mod.args.hyphenDataDirPath, err)
}
return nil
@@ -685,19 +731,46 @@ func (mod *Chromium) Stop(ctx context.Context) error {
// Debug returns additional debug data.
func (mod *Chromium) Debug() map[string]any {
debug := make(map[string]any)
return map[string]any{"version": mod.detectVersion()}
}
cmd := exec.Command(mod.args.binPath, "--version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
// detectVersion resolves the Chromium version once, preferring the value
// captured at image build time so it never spawns Chromium at runtime. It falls
// back to running chromium --version for local or non-Docker builds.
func (mod *Chromium) detectVersion() string {
mod.versionOnce.Do(func() {
if v, ok := gotenberg.BuildVersion("chromium"); ok {
mod.version = v
return
}
output, err := cmd.Output()
if err != nil {
debug["version"] = err.Error()
return debug
cmd := exec.Command(mod.args.binPath, "--version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
output, err := cmd.Output()
if err != nil {
mod.version = err.Error()
return
}
mod.version = strings.TrimSpace(string(output))
})
return mod.version
}
// spanAttrs returns the client-span attributes for a Chromium invocation: the
// server address and the Chromium version, plus any extra attributes. The
// version rides on every conversion span so a trace records which Chromium
// rendered the document.
func (mod *Chromium) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue {
attrs := make([]attribute.KeyValue, 0, 2+len(extra))
attrs = append(attrs, semconv.ServerAddress(mod.args.binPath))
if v := mod.detectVersion(); v != "" {
attrs = append(attrs, attribute.String("gotenberg.chromium.version", v))
}
debug["version"] = strings.TrimSpace(string(output))
return debug
return append(attrs, extra...)
}
// Metrics returns the metrics.
@@ -787,19 +860,32 @@ func (mod *Chromium) Routes() ([]api.Route, error) {
}
// Pdf converts a URL to PDF.
//
//nolint:dupl
func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
// Read input attributes before Start rebinds ctx to the span context, which
// would shadow the underlying [api.Context].
inputAttrs := conversionInputAttrs(ctx, url)
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Pdf",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)),
trace.WithAttributes(mod.spanAttrs()...),
)
defer span.End()
span.SetAttributes(inputAttrs...)
span.SetAttributes(
attribute.Int64("gotenberg.queue.depth_at_arrival", mod.supervisor.ReqQueueSize()),
attribute.Int64("gotenberg.conversions_since_last_restart", mod.supervisor.ConversionsSinceRestart()),
)
start := time.Now()
var conversionStart time.Time
aggregate := newNetworkAggregate()
err := mod.supervisor.Run(ctx, logger, func() error {
conversionStart = time.Now()
return mod.browser.pdf(ctx, logger, url, outputPath, options)
return mod.browser.pdf(ctx, logger, url, outputPath, options, aggregate)
})
end := time.Now()
@@ -812,21 +898,12 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
status = "error"
}
reason := "unknown"
switch {
case errors.Is(err, context.DeadlineExceeded):
reason = "timeout"
case errors.Is(err, context.Canceled):
reason = "context_cancelled"
case errors.Is(err, ErrInvalidHttpStatusCode) || errors.Is(err, ErrInvalidResourceHttpStatusCode) || errors.Is(err, ErrLoadingFailed) || errors.Is(err, ErrResourceLoadingFailed) || errors.Is(err, ErrInvalidEvaluationExpression) || errors.Is(err, ErrInvalidSelectorQuery):
reason = "invalid_input"
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) || errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
reason = "chromium_unavailable"
}
reason := chromiumErrorType(err, "chromium_unavailable")
mod.errsCounter.Add(ctx, 1, metric.WithAttributes(
attribute.String("reason", reason),
))
gotenberg.SpanErrorType(span, reason)
}
if !conversionStart.IsZero() {
@@ -850,9 +927,12 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
attribute.String("status", status),
))
mod.recordNetwork(ctx, span, aggregate)
if err == nil {
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
mod.pdfOutputSizeCounter.Record(ctx, fileInfo.Size())
span.SetAttributes(attribute.Int64("gotenberg.conversion.output.bytes", fileInfo.Size()))
}
span.SetStatus(codes.Ok, "")
@@ -864,19 +944,28 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
return err
}
// Screenshot captures a screenshot from a URL.
//
//nolint:dupl
func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Screenshot",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)),
trace.WithAttributes(mod.spanAttrs()...),
)
defer span.End()
span.SetAttributes(
attribute.Int64("gotenberg.queue.depth_at_arrival", mod.supervisor.ReqQueueSize()),
attribute.Int64("gotenberg.conversions_since_last_restart", mod.supervisor.ConversionsSinceRestart()),
)
start := time.Now()
var conversionStart time.Time
aggregate := newNetworkAggregate()
err := mod.supervisor.Run(ctx, logger, func() error {
conversionStart = time.Now()
return mod.browser.screenshot(ctx, logger, url, outputPath, options)
return mod.browser.screenshot(ctx, logger, url, outputPath, options, aggregate)
})
end := time.Now()
@@ -889,23 +978,12 @@ func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, o
status = "error"
}
reason := "unknown"
switch {
case errors.Is(err, context.DeadlineExceeded):
reason = "timeout"
case errors.Is(err, context.Canceled):
reason = "context_cancelled"
case errors.Is(err, ErrInvalidHttpStatusCode) || errors.Is(err, ErrInvalidResourceHttpStatusCode) || errors.Is(err, ErrLoadingFailed) || errors.Is(err, ErrResourceLoadingFailed) || errors.Is(err, ErrInvalidEvaluationExpression) || errors.Is(err, ErrInvalidSelectorQuery):
reason = "invalid_input"
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded):
reason = "chromium_maximum_queue_size_exceeded"
case errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
reason = "chromium_unavailable"
}
reason := chromiumErrorType(err, "chromium_maximum_queue_size_exceeded")
mod.errsCounter.Add(ctx, 1, metric.WithAttributes(
attribute.String("reason", reason),
))
gotenberg.SpanErrorType(span, reason)
}
if !conversionStart.IsZero() {
@@ -929,6 +1007,8 @@ func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, o
attribute.String("status", status),
))
mod.recordNetwork(ctx, span, aggregate)
if err == nil {
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
mod.imageOutputSizeCounter.Record(ctx, fileInfo.Size())
@@ -943,6 +1023,86 @@ func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, o
return err
}
// recordNetwork lifts per-conversion network aggregates onto the span and the
// network metrics. Counts are dimensioned by outcome and bytes feed a
// histogram; both are recorded with the conversion context so the SDK attaches
// trace exemplars. The heaviest resource URL is redacted before it lands on the
// span event.
func (mod *Chromium) recordNetwork(ctx context.Context, span trace.Span, aggregate *networkAggregate) {
if aggregate == nil {
return
}
stats := aggregate.snapshot()
span.SetAttributes(
attribute.Int64("gotenberg.chromium.resources.count", stats.requestCount),
attribute.Int64("gotenberg.chromium.resources.bytes_total", stats.bytesTotal),
attribute.Int64("gotenberg.chromium.resources.failed_count", stats.failedCount),
attribute.Int64("gotenberg.chromium.resources.unique_origins", stats.uniqueOrigins),
)
if stats.heaviestURL != "" {
span.AddEvent("chromium.heaviest_resource", trace.WithAttributes(
attribute.String("url", gotenberg.RedactURL(stats.heaviestURL)),
attribute.Int64("bytes", stats.heaviestBytes),
))
}
if ok := stats.requestCount - stats.failedCount; ok > 0 {
mod.networkRequestsCounter.Add(ctx, ok, metric.WithAttributes(attribute.String("outcome", "ok")))
}
if stats.failedCount > 0 {
mod.networkRequestsCounter.Add(ctx, stats.failedCount, metric.WithAttributes(attribute.String("outcome", "failed")))
}
mod.networkBytesCounter.Record(ctx, stats.bytesTotal)
}
// conversionInputAttrs derives low-cardinality input attributes for a
// conversion span: the number of received files (when ctx is an [api.Context])
// and the size of the local HTML input (when url is a file:// URL). Remote URL
// conversions yield no html.bytes.
func conversionInputAttrs(ctx context.Context, url string) []attribute.KeyValue {
var attrs []attribute.KeyValue
if apiCtx, ok := ctx.(*api.Context); ok {
attrs = append(attrs, attribute.Int("gotenberg.conversion.input.files.count", apiCtx.FileCount()))
}
if after, ok := strings.CutPrefix(url, "file://"); ok {
if info, err := os.Stat(after); err == nil {
attrs = append(attrs, attribute.Int64("gotenberg.conversion.input.html.bytes", info.Size()))
}
}
return attrs
}
// chromiumErrorType maps a conversion error to chromium's bounded reason value,
// reused as the span error.type. queueReason preserves the historical,
// route-specific label for a saturated queue: Pdf collapses it into
// "chromium_unavailable", whereas Screenshot keeps
// "chromium_maximum_queue_size_exceeded". Generic failures fall back to
// [gotenberg.ClassifyError].
func chromiumErrorType(err error, queueReason string) string {
switch {
case errors.Is(err, ErrInvalidHttpStatusCode),
errors.Is(err, ErrInvalidResourceHttpStatusCode),
errors.Is(err, ErrLoadingFailed),
errors.Is(err, ErrResourceLoadingFailed),
errors.Is(err, ErrInvalidEvaluationExpression),
errors.Is(err, ErrInvalidSelectorQuery):
return gotenberg.ErrorTypeInvalidInput
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded):
return queueReason
case errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
return "chromium_unavailable"
default:
return gotenberg.ClassifyError(err)
}
}
// Interface guards.
var (
_ gotenberg.Module = (*Chromium)(nil)

View File

@@ -0,0 +1,37 @@
package chromium
import (
"context"
"errors"
"testing"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestChromiumErrorType(t *testing.T) {
for _, tc := range []struct {
name string
err error
queueReason string
want string
}{
{"deadline", context.DeadlineExceeded, "chromium_unavailable", "timeout"},
{"canceled", context.Canceled, "chromium_unavailable", "context_cancelled"},
{"invalid http status", ErrInvalidHttpStatusCode, "chromium_unavailable", "invalid_input"},
{"invalid resource http status", ErrInvalidResourceHttpStatusCode, "chromium_unavailable", "invalid_input"},
{"loading failed", ErrLoadingFailed, "chromium_unavailable", "invalid_input"},
{"resource loading failed", ErrResourceLoadingFailed, "chromium_unavailable", "invalid_input"},
{"invalid evaluation expression", ErrInvalidEvaluationExpression, "chromium_unavailable", "invalid_input"},
{"invalid selector query", ErrInvalidSelectorQuery, "chromium_unavailable", "invalid_input"},
{"pdf queue", gotenberg.ErrMaximumQueueSizeExceeded, "chromium_unavailable", "chromium_unavailable"},
{"screenshot queue", gotenberg.ErrMaximumQueueSizeExceeded, "chromium_maximum_queue_size_exceeded", "chromium_maximum_queue_size_exceeded"},
{"restarting", gotenberg.ErrProcessAlreadyRestarting, "chromium_maximum_queue_size_exceeded", "chromium_unavailable"},
{"unknown", errors.New("boom"), "chromium_unavailable", "unknown"},
} {
t.Run(tc.name, func(t *testing.T) {
if got := chromiumErrorType(tc.err, tc.queueReason); got != tc.want {
t.Errorf("chromiumErrorType(%v, %q) = %q, want %q", tc.err, tc.queueReason, got, tc.want)
}
})
}
}

View File

@@ -10,6 +10,7 @@ import (
"slices"
"strings"
"sync"
"time"
"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/cdproto/fetch"
@@ -23,8 +24,30 @@ import (
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// listenForNetworkActivity accumulates per-conversion network activity into
// aggregate from the always-on Network domain events. It is a no-op when
// aggregate is nil.
func listenForNetworkActivity(ctx context.Context, aggregate *networkAggregate) {
if aggregate == nil {
return
}
chromedp.ListenTarget(ctx, func(ev any) {
switch e := ev.(type) {
case *network.EventResponseReceived:
aggregate.onResponseReceived(e)
case *network.EventLoadingFinished:
aggregate.onLoadingFinished(e)
case *network.EventLoadingFailed:
aggregate.onLoadingFailed(e)
}
})
}
type eventRequestPausedOptions struct {
allowList, denyList []*regexp2.Regexp
denyPrivateIPs bool
denyPublicIPs bool
allowedFilePrefixes []string
extraHttpHeaders []ExtraHttpHeader
}
@@ -40,6 +63,11 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
logger.DebugContext(ctx, fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders))
}
// Shared by every scope match of this conversion, across all paused
// requests. Its lifetime is the conversion, as this function is called once
// per conversion with that conversion's context.
budget := newScopeMatchBudget(scopeMatchBudgetPerConversion)
chromedp.ListenTarget(ctx, func(ev any) {
if e, ok := ev.(*fetch.EventRequestPaused); ok {
go func() {
@@ -52,35 +80,42 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
return
}
err := gotenberg.FilterDeadline(options.allowList, options.denyList, e.Request.URL, deadline)
err := gotenberg.FilterOutboundURL(ctx, e.Request.URL, options.allowList, options.denyList, deadline,
gotenberg.WithDenyPrivateIPs(options.denyPrivateIPs),
gotenberg.WithDenyPublicIPs(options.denyPublicIPs),
)
if err != nil {
logger.WarnContext(ctx, err.Error())
allow = false
}
// Additional restriction: if the sub-resource is a file:// URL
// and we have allowed file prefixes, restrict access to only
// those directories. This prevents cross-request file access
// in /tmp.
if allow && strings.HasPrefix(e.Request.URL, "file://") && len(options.allowedFilePrefixes) > 0 {
prefixMatch := false
for _, prefix := range options.allowedFilePrefixes {
if strings.HasPrefix(e.Request.URL, "file://"+prefix) {
prefixMatch = true
break
}
}
if !prefixMatch {
logger.WarnContext(ctx, fmt.Sprintf("'%s' is not within any allowed file prefix", e.Request.URL))
allow = false
}
// Sub-resource file:// URLs are opt-in per route. A route
// that renders local files (HTML, Markdown) populates
// allowedFilePrefixes with the request working directory
// so its own assets load while sibling requests' /tmp
// paths stay out of reach. Every other route leaves the
// slice empty; treat that as default-deny so a file://
// sub-resource that slips past the deny-list (which
// exempts /tmp/) still cannot read the working
// directories of other in-flight conversions.
if allow && strings.HasPrefix(e.Request.URL, "file://") && !isAllowedFileSubResource(e.Request.URL, options.allowedFilePrefixes) {
logger.WarnContext(ctx, fmt.Sprintf("'%s' is not within any allowed file prefix", e.Request.URL))
allow = false
}
cctx := chromedp.FromContext(ctx)
executorCtx := cdp.WithExecutor(ctx, cctx.Target)
if !allow {
// Use AccessDenied so Chromium emits net::ERR_ACCESS_DENIED,
// which is intentionally absent from the EventLoadingFailed
// known-errors list. Routing through BlockedByClient would
// surface the failure, but the Document-type dispatcher in
// listenForEventLoadingFailed cannot distinguish a blocked
// iframe (sub-frame Document) from a main-page Document, and
// would attribute the iframe failure to the main page.
// Filter-block observability is provided by the warn log
// above instead.
req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
err = req.Do(executorCtx)
if err != nil {
@@ -98,6 +133,14 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
// First, we have to check if at least one header has to be
// set for the current request.
for _, header := range options.extraHttpHeaders {
// This goroutine outlives the response: nothing cancels an
// in-flight match, so stop as soon as the conversion is over.
select {
case <-ctx.Done():
return
default:
}
if header.Scope == nil {
// Non-scoped header.
logger.DebugContext(ctx, fmt.Sprintf("extra HTTP header '%s' will be set for request URL '%s'", header.Name, e.Request.URL))
@@ -105,7 +148,18 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
continue
}
if !budget.tryAcquire() {
// Treat the remaining scoped headers as non-matching rather
// than spending more CPU on a request the client may already
// have given up on.
logger.WarnContext(ctx, fmt.Sprintf("scope matching budget of %s exhausted, extra HTTP header '%s' and any subsequent scoped header will not be set; simplify the 'scope' patterns or reduce the number of scoped headers", scopeMatchBudgetPerConversion, header.Name))
break
}
matchStart := time.Now()
ok, err := header.Scope.MatchString(e.Request.URL)
budget.consume(time.Since(matchStart))
switch {
case err != nil:
logger.ErrorContext(ctx, fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err))
@@ -240,6 +294,23 @@ func listenForEventResponseReceived(
})
}
// isAllowedFileSubResource reports whether a file:// sub-resource URL is
// within at least one prefix. An empty prefix list rejects every
// file:// URL so routes that never populate the list (for example
// /forms/chromium/convert/url) default-deny reads from /tmp/, blocking
// cross-request enumeration.
func isAllowedFileSubResource(rawURL string, allowedFilePrefixes []string) bool {
if len(allowedFilePrefixes) == 0 {
return false
}
for _, prefix := range allowedFilePrefixes {
if strings.HasPrefix(rawURL, "file://"+prefix) {
return true
}
}
return false
}
func shouldCheckResourceHttpStatusCode(rawURL string, ignoreDomains []string) bool {
host := hostnameFromURL(rawURL)
@@ -402,19 +473,24 @@ func listenForEventExceptionThrown(ctx context.Context, logger *slog.Logger, con
})
}
// waitForEventDomContentEventFired waits until the event DomContentEventFired
// is fired or the context timeout.
// waitForEventDomContentEventFired registers a listener for the
// DomContentEventFired event and returns a waiter that blocks until the
// event fires or ctx is done. The listener registers at call time, not
// inside the waiter, so callers must invoke this before triggering the
// action that may emit the event. Registering inside the waiter would
// open a race: for fast loads (typically file:// pages with no external
// sub-resources), the event can fire before the waiter goroutine starts
// and the listener never sees a record.
func waitForEventDomContentEventFired(ctx context.Context, logger *slog.Logger) func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if _, ok := ev.(*page.EventDomContentEventFired); ok {
cancel()
close(ch)
}
})
return func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if _, ok := ev.(*page.EventDomContentEventFired); ok {
cancel()
close(ch)
}
})
select {
case <-ch:
logger.DebugContext(ctx, "event DomContentEventFired fired")
@@ -425,19 +501,20 @@ func waitForEventDomContentEventFired(ctx context.Context, logger *slog.Logger)
}
}
// waitForEventLoadEventFired waits until the event LoadEventFired is fired or
// the context timeout.
// waitForEventLoadEventFired registers a listener for the LoadEventFired
// event and returns a waiter that blocks until the event fires or ctx is
// done. See [waitForEventDomContentEventFired] for the rationale on
// registering at call time rather than inside the waiter.
func waitForEventLoadEventFired(ctx context.Context, logger *slog.Logger) func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if _, ok := ev.(*page.EventLoadEventFired); ok {
cancel()
close(ch)
}
})
return func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if _, ok := ev.(*page.EventLoadEventFired); ok {
cancel()
close(ch)
}
})
select {
case <-ch:
logger.DebugContext(ctx, "event LoadEventFired fired")
@@ -448,19 +525,20 @@ func waitForEventLoadEventFired(ctx context.Context, logger *slog.Logger) func()
}
}
// waitForEventNetworkIdle waits until the event networkIdle is fired or the
// context timeout.
// waitForEventNetworkIdle registers a listener for the networkIdle
// lifecycle event and returns a waiter that blocks until the event fires
// or ctx is done. See [waitForEventDomContentEventFired] for the
// rationale on registering at call time rather than inside the waiter.
func waitForEventNetworkIdle(ctx context.Context, logger *slog.Logger) func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle" {
cancel()
close(ch)
}
})
return func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle" {
cancel()
close(ch)
}
})
select {
case <-ch:
logger.DebugContext(ctx, "event networkIdle fired")
@@ -471,19 +549,20 @@ func waitForEventNetworkIdle(ctx context.Context, logger *slog.Logger) func() er
}
}
// waitForEventNetworkAlmostIdle waits until the event networkIdle2 is fired
// or the context timeout.
// waitForEventNetworkAlmostIdle registers a listener for the networkIdle2
// lifecycle event and returns a waiter that blocks until the event fires
// or ctx is done. See [waitForEventDomContentEventFired] for the
// rationale on registering at call time rather than inside the waiter.
func waitForEventNetworkAlmostIdle(ctx context.Context, logger *slog.Logger) func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle2" {
cancel()
close(ch)
}
})
return func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle2" {
cancel()
close(ch)
}
})
select {
case <-ch:
logger.DebugContext(ctx, "event networkAlmostIdle fired")
@@ -494,19 +573,20 @@ func waitForEventNetworkAlmostIdle(ctx context.Context, logger *slog.Logger) fun
}
}
// waitForEventLoadingFinished waits until the event LoadingFinished is fired
// or the context timeout.
// waitForEventLoadingFinished registers a listener for the
// LoadingFinished event and returns a waiter that blocks until the event
// fires or ctx is done. See [waitForEventDomContentEventFired] for the
// rationale on registering at call time rather than inside the waiter.
func waitForEventLoadingFinished(ctx context.Context, logger *slog.Logger) func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if _, ok := ev.(*network.EventLoadingFinished); ok {
cancel()
close(ch)
}
})
return func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if _, ok := ev.(*network.EventLoadingFinished); ok {
cancel()
close(ch)
}
})
select {
case <-ch:
logger.DebugContext(ctx, "event LoadingFinished fired")

View File

@@ -61,3 +61,49 @@ func TestShouldCheckResourceHttpStatusCode_NonHTTPURL(t *testing.T) {
t.Fatalf("expected data: URL to be checked (no host filtering possible)")
}
}
func TestIsAllowedFileSubResource(t *testing.T) {
for _, tc := range []struct {
name string
rawURL string
prefixes []string
want bool
}{
{
name: "empty prefix list default denies",
rawURL: "file:///tmp/work-uuid/request-uuid/index.html",
prefixes: nil,
want: false,
},
{
name: "match within the sole prefix",
rawURL: "file:///tmp/work-uuid/request-uuid/index.html",
prefixes: []string{"/tmp/work-uuid/request-uuid"},
want: true,
},
{
name: "sibling request directory rejected",
rawURL: "file:///tmp/work-uuid/other-request-uuid/secret.html",
prefixes: []string{"/tmp/work-uuid/request-uuid"},
want: false,
},
{
name: "parent tmp directory rejected",
rawURL: "file:///tmp/",
prefixes: []string{"/tmp/work-uuid/request-uuid"},
want: false,
},
{
name: "match among several prefixes",
rawURL: "file:///tmp/work-uuid/request-b/asset.css",
prefixes: []string{"/tmp/work-uuid/request-a", "/tmp/work-uuid/request-b"},
want: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
if got := isAllowedFileSubResource(tc.rawURL, tc.prefixes); got != tc.want {
t.Fatalf("isAllowedFileSubResource(%q, %v) = %v, want %v", tc.rawURL, tc.prefixes, got, tc.want)
}
})
}
}

View File

@@ -24,16 +24,16 @@ func (api *ApiMock) Screenshot(ctx context.Context, logger *slog.Logger, url, ou
// browserMock is a mock for the [browser] interface.
type browserMock struct {
gotenberg.ProcessMock
pdfMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error
screenshotMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error
pdfMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions, aggregate *networkAggregate) error
screenshotMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions, aggregate *networkAggregate) error
}
func (b *browserMock) pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
return b.pdfMock(ctx, logger, url, outputPath, options)
func (b *browserMock) pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions, aggregate *networkAggregate) error {
return b.pdfMock(ctx, logger, url, outputPath, options, aggregate)
}
func (b *browserMock) screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
return b.screenshotMock(ctx, logger, url, outputPath, options)
func (b *browserMock) screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions, aggregate *networkAggregate) error {
return b.screenshotMock(ctx, logger, url, outputPath, options, aggregate)
}
// Interface guards.

View File

@@ -0,0 +1,121 @@
package chromium
import (
"net/url"
"sync"
"github.com/chromedp/cdproto/network"
)
// maxTrackedOrigins bounds the distinct origins kept per conversion so a
// pathological page cannot grow the set without limit.
const maxTrackedOrigins = 64
// networkAggregate accumulates per-conversion network activity from Chromium
// DevTools events. It is safe for concurrent use by the chromedp event listener
// goroutine and the conversion goroutine that reads the snapshot afterwards.
type networkAggregate struct {
mu sync.Mutex
requestCount int64
bytesTotal int64
failedCount int64
origins map[string]struct{}
requestURLByID map[network.RequestID]string
heaviestURL string
heaviestBytes int64
}
// networkStats is an immutable snapshot of a [networkAggregate].
type networkStats struct {
requestCount int64
bytesTotal int64
failedCount int64
uniqueOrigins int64
heaviestURL string
heaviestBytes int64
}
func newNetworkAggregate() *networkAggregate {
return &networkAggregate{
origins: make(map[string]struct{}),
requestURLByID: make(map[network.RequestID]string),
}
}
// onResponseReceived records the response origin and remembers the URL for the
// request id, so a later loading-finished event can attribute its bytes.
func (a *networkAggregate) onResponseReceived(ev *network.EventResponseReceived) {
if ev == nil || ev.Response == nil {
return
}
a.mu.Lock()
defer a.mu.Unlock()
if origin := originOf(ev.Response.URL); origin != "" {
if _, ok := a.origins[origin]; !ok && len(a.origins) < maxTrackedOrigins {
a.origins[origin] = struct{}{}
}
}
a.requestURLByID[ev.RequestID] = ev.Response.URL
}
// onLoadingFinished records a successfully completed request and its size,
// tracking the single heaviest resource.
func (a *networkAggregate) onLoadingFinished(ev *network.EventLoadingFinished) {
if ev == nil {
return
}
a.mu.Lock()
defer a.mu.Unlock()
a.requestCount++
size := int64(ev.EncodedDataLength)
a.bytesTotal += size
if size > a.heaviestBytes {
a.heaviestBytes = size
a.heaviestURL = a.requestURLByID[ev.RequestID]
}
}
// onLoadingFailed records a request that failed to complete.
func (a *networkAggregate) onLoadingFailed(ev *network.EventLoadingFailed) {
if ev == nil {
return
}
a.mu.Lock()
defer a.mu.Unlock()
a.requestCount++
a.failedCount++
}
func (a *networkAggregate) snapshot() networkStats {
a.mu.Lock()
defer a.mu.Unlock()
return networkStats{
requestCount: a.requestCount,
bytesTotal: a.bytesTotal,
failedCount: a.failedCount,
uniqueOrigins: int64(len(a.origins)),
heaviestURL: a.heaviestURL,
heaviestBytes: a.heaviestBytes,
}
}
// originOf returns the scheme://host of rawURL, or an empty string when it has
// no host (for example data: or file: URLs).
func originOf(rawURL string) string {
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Host == "" {
return ""
}
return parsed.Scheme + "://" + parsed.Host
}

View File

@@ -0,0 +1,100 @@
package chromium
import (
"fmt"
"sync"
"testing"
"github.com/chromedp/cdproto/network"
)
func TestOriginOf(t *testing.T) {
for _, tc := range []struct {
raw string
want string
}{
{"https://example.com/path?q=1", "https://example.com"},
{"http://cdn.example.com:8080/a.js", "http://cdn.example.com:8080"},
{"data:image/png;base64,AAAA", ""},
{"file:///tmp/index.html", ""},
{"not a url", ""},
} {
if got := originOf(tc.raw); got != tc.want {
t.Errorf("originOf(%q) = %q, want %q", tc.raw, got, tc.want)
}
}
}
func TestNetworkAggregate_Snapshot(t *testing.T) {
a := newNetworkAggregate()
a.onResponseReceived(&network.EventResponseReceived{
RequestID: "1",
Response: &network.Response{URL: "https://example.com/a.js"},
})
a.onResponseReceived(&network.EventResponseReceived{
RequestID: "2",
Response: &network.Response{URL: "https://cdn.example.com/b.png"},
})
// Duplicate origin must not grow the set.
a.onResponseReceived(&network.EventResponseReceived{
RequestID: "3",
Response: &network.Response{URL: "https://example.com/c.css"},
})
a.onLoadingFinished(&network.EventLoadingFinished{RequestID: "1", EncodedDataLength: 100})
a.onLoadingFinished(&network.EventLoadingFinished{RequestID: "2", EncodedDataLength: 900})
a.onLoadingFailed(&network.EventLoadingFailed{RequestID: "3"})
got := a.snapshot()
if got.requestCount != 3 {
t.Errorf("requestCount = %d, want 3", got.requestCount)
}
if got.bytesTotal != 1000 {
t.Errorf("bytesTotal = %d, want 1000", got.bytesTotal)
}
if got.failedCount != 1 {
t.Errorf("failedCount = %d, want 1", got.failedCount)
}
if got.uniqueOrigins != 2 {
t.Errorf("uniqueOrigins = %d, want 2", got.uniqueOrigins)
}
if got.heaviestBytes != 900 || got.heaviestURL != "https://cdn.example.com/b.png" {
t.Errorf("heaviest = (%q, %d), want (%q, 900)", got.heaviestURL, got.heaviestBytes, "https://cdn.example.com/b.png")
}
}
func TestNetworkAggregate_OriginCap(t *testing.T) {
a := newNetworkAggregate()
for i := range maxTrackedOrigins + 50 {
a.onResponseReceived(&network.EventResponseReceived{
RequestID: network.RequestID(fmt.Sprintf("r%d", i)),
Response: &network.Response{URL: fmt.Sprintf("https://host%d.example.com/x", i)},
})
}
if got := a.snapshot().uniqueOrigins; got != maxTrackedOrigins {
t.Errorf("uniqueOrigins = %d, want %d (capped)", got, maxTrackedOrigins)
}
}
func TestNetworkAggregate_ConcurrentSafe(t *testing.T) {
a := newNetworkAggregate()
var wg sync.WaitGroup
for i := range 100 {
wg.Add(1)
go func(i int) {
defer wg.Done()
id := network.RequestID(fmt.Sprintf("r%d", i))
a.onResponseReceived(&network.EventResponseReceived{
RequestID: id,
Response: &network.Response{URL: fmt.Sprintf("https://host%d.example.com/x", i)},
})
a.onLoadingFinished(&network.EventLoadingFinished{RequestID: id, EncodedDataLength: 10})
}(i)
}
wg.Wait()
if got := a.snapshot().requestCount; got != 100 {
t.Errorf("requestCount = %d, want 100", got)
}
}

View File

@@ -0,0 +1,426 @@
package chromium
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/netip"
"net/url"
"sync"
"time"
"github.com/dlclark/regexp2"
"golang.org/x/net/http/httpproxy"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// pinningProxy is a loopback-bound HTTP/1.1 forward and CONNECT proxy
// placed between Chromium and the outbound network. It runs the same
// allow/deny/IP-public validation as [gotenberg.FilterOutboundURL] on
// every request and dials the destination using the IPs resolved at that
// moment. Routing Chromium through this proxy eliminates the Chromium-side
// DNS lookup that otherwise opens a DNS rebinding window between
// Gotenberg's validation and Chromium's TCP connect.
//
// The proxy is transparent to the caller. HTTPS sub-resources tunnel
// through CONNECT with Chromium performing its own TLS handshake using
// the original hostname, preserving SNI and certificate validation.
type pinningProxy struct {
allowList []*regexp2.Regexp
denyList []*regexp2.Regexp
// decide resolves and validates a URL. Tests may override it.
decide func(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time) (gotenberg.OutboundDecision, error)
// dialPinned dials the pinned IPs for a decision. Tests may override
// it to connect to a stub upstream regardless of decision.
dialPinned func(ctx context.Context, network string, addrs []netip.Addr, port string) (net.Conn, error)
// dialBypass dials the destination hostname directly (operator
// allow-list opt-in). Tests may override it.
dialBypass func(ctx context.Context, network, addr string) (net.Conn, error)
// upstreamProxy resolves the upstream (corporate) proxy for a
// destination URL from the standard proxy environment variables, or
// returns a nil URL to connect directly. It is nil unless the operator
// opted into proxy-environment honoring. When set, the pinning proxy
// performs the authenticated proxy handshake that Chromium cannot. See
// https://github.com/gotenberg/gotenberg/issues/1592.
upstreamProxy func(*url.URL) (*url.URL, error)
listener net.Listener
server *http.Server
wg sync.WaitGroup
logger *slog.Logger
started bool
mu sync.Mutex
}
// newPinningProxy returns a pinning proxy configured with the given
// allow/deny lists and IP-class policy. The policy bools are applied via
// [gotenberg.DecideOutbound] on every request the proxy sees, so
// Chromium inherits whatever posture the operator selected. The
// returned proxy is not yet listening; call Start.
func newPinningProxy(allowList, denyList []*regexp2.Regexp, denyPrivateIPs, denyPublicIPs, enableEnvironmentProxy bool) *pinningProxy {
p := &pinningProxy{
allowList: allowList,
denyList: denyList,
decide: func(ctx context.Context, rawURL string, allow, deny []*regexp2.Regexp, deadline time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.DecideOutbound(ctx, rawURL, allow, deny, deadline,
gotenberg.WithDenyPrivateIPs(denyPrivateIPs),
gotenberg.WithDenyPublicIPs(denyPublicIPs),
)
},
dialPinned: gotenberg.DialPinned,
dialBypass: func(ctx context.Context, network, addr string) (net.Conn, error) {
dialer := &net.Dialer{Timeout: 10 * time.Second}
return dialer.DialContext(ctx, network, addr)
},
}
if enableEnvironmentProxy {
// Honor the standard proxy environment variables, credentials
// included. httpproxy reads the environment now and applies NO_PROXY.
p.upstreamProxy = httpproxy.FromEnvironment().ProxyFunc()
}
return p
}
// Start binds the proxy to 127.0.0.1 on an ephemeral port and serves in a
// background goroutine. Bind failures return an error; the caller must
// not proceed to start Chromium with --proxy-server.
func (p *pinningProxy) Start(logger *slog.Logger) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.started {
return errors.New("pinning proxy already started")
}
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return fmt.Errorf("bind pinning proxy: %w", err)
}
p.listener = l
p.logger = logger.With(slog.String("logger", "pinning-proxy"))
p.server = &http.Server{
Handler: http.HandlerFunc(p.serveHTTP),
// Guard against slow header attacks. Body reads are controlled
// per-handler.
ReadHeaderTimeout: 15 * time.Second,
ErrorLog: slog.NewLogLogger(p.logger.Handler(), slog.LevelWarn),
}
p.wg.Go(func() {
serveErr := p.server.Serve(l)
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
p.logger.ErrorContext(context.Background(), fmt.Sprintf("pinning proxy serve: %s", serveErr))
}
})
p.started = true
p.logger.DebugContext(context.Background(), fmt.Sprintf("pinning proxy listening on %s", l.Addr()))
return nil
}
// Stop shuts the proxy down and waits for in-flight handlers to complete.
// Safe to call on a non-started proxy.
func (p *pinningProxy) Stop(logger *slog.Logger) error {
p.mu.Lock()
if !p.started {
p.mu.Unlock()
return nil
}
srv := p.server
p.started = false
p.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
shutdownErr := srv.Shutdown(ctx)
p.wg.Wait()
if shutdownErr != nil {
return fmt.Errorf("shutdown pinning proxy: %w", shutdownErr)
}
logger.DebugContext(context.Background(), "pinning proxy stopped")
return nil
}
// URL returns the proxy URL suitable for Chromium's --proxy-server flag.
// Returns an empty string when the proxy is not listening.
func (p *pinningProxy) URL() string {
p.mu.Lock()
defer p.mu.Unlock()
if p.listener == nil {
return ""
}
return "http://" + p.listener.Addr().String()
}
func (p *pinningProxy) serveHTTP(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodConnect {
p.handleConnect(w, req)
return
}
p.handleForward(w, req)
}
// handleConnect handles HTTPS (and any other CONNECT) tunnels. Chromium
// issues CONNECT host:port; the proxy validates the host, dials the
// pinned IP, and splices the client socket with the upstream socket.
// Chromium then negotiates TLS end-to-end with the original hostname in
// SNI.
func (p *pinningProxy) handleConnect(w http.ResponseWriter, req *http.Request) {
_, port, err := net.SplitHostPort(req.Host)
if err != nil {
http.Error(w, "bad CONNECT target", http.StatusBadRequest)
return
}
deadline, ok := req.Context().Deadline()
if !ok {
deadline = time.Now().Add(30 * time.Second)
}
// The validation URL uses https:// so that http-like scheme checks
// apply in [gotenberg.DecideOutbound]. The scheme does not influence
// the CONNECT handling beyond filtering.
decision, err := p.decide(req.Context(), "https://"+req.Host, p.allowList, p.denyList, deadline)
if err != nil {
if isClientCancellation(req.Context(), err) {
p.logger.DebugContext(req.Context(), fmt.Sprintf("CONNECT abandoned by client for '%s': %s", req.Host, err))
} else {
p.logger.WarnContext(req.Context(), fmt.Sprintf("CONNECT blocked for '%s': %s", req.Host, err))
}
http.Error(w, "CONNECT blocked", http.StatusForbidden)
return
}
// When the operator routes egress through an authenticated proxy,
// Chromium cannot supply the credentials itself, so the pinning proxy
// performs the CONNECT (and authentication) upstream. The decision above
// still gated the destination through the allow/deny and IP-class rules.
var proxyURL *url.URL
if p.upstreamProxy != nil {
proxyURL, err = p.upstreamProxy(&url.URL{Scheme: "https", Host: req.Host})
if err != nil {
p.logger.WarnContext(req.Context(), fmt.Sprintf("resolve upstream proxy for '%s': %s", req.Host, err))
http.Error(w, "upstream proxy error", http.StatusBadGateway)
return
}
}
var upstream net.Conn
switch {
case proxyURL != nil:
upstream, err = p.dialThroughUpstreamProxy(req.Context(), proxyURL, req.Host)
case decision.Bypass:
upstream, err = p.dialBypass(req.Context(), "tcp", req.Host)
case len(decision.Pinned) > 0:
upstream, err = p.dialPinned(req.Context(), "tcp", decision.Pinned, port)
default:
err = errors.New("no pinned addresses and not bypassed")
}
if err != nil {
if isClientCancellation(req.Context(), err) {
p.logger.DebugContext(req.Context(), fmt.Sprintf("CONNECT dial abandoned by client for '%s': %s", req.Host, err))
} else {
p.logger.WarnContext(req.Context(), fmt.Sprintf("CONNECT dial failed for '%s': %s", req.Host, err))
}
http.Error(w, "upstream dial failed", http.StatusBadGateway)
return
}
defer upstream.Close()
hj, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "hijack unsupported", http.StatusInternalServerError)
return
}
client, _, err := hj.Hijack()
if err != nil {
p.logger.ErrorContext(req.Context(), fmt.Sprintf("hijack CONNECT: %s", err))
return
}
defer client.Close()
_, err = client.Write([]byte("HTTP/1.1 200 OK\r\n\r\n"))
if err != nil {
if isClientCancellation(req.Context(), err) {
p.logger.DebugContext(req.Context(), fmt.Sprintf("write CONNECT ack abandoned by client: %s", err))
} else {
p.logger.WarnContext(req.Context(), fmt.Sprintf("write CONNECT ack: %s", err))
}
return
}
// Splice bytes in both directions until either side closes.
var splice sync.WaitGroup
splice.Add(2)
go func() {
defer splice.Done()
_, _ = io.Copy(upstream, client)
if cw, ok := upstream.(interface{ CloseWrite() error }); ok {
_ = cw.CloseWrite()
}
}()
go func() {
defer splice.Done()
_, _ = io.Copy(client, upstream)
if cw, ok := client.(interface{ CloseWrite() error }); ok {
_ = cw.CloseWrite()
}
}()
splice.Wait()
}
// handleForward handles plain HTTP requests sent to the proxy as absolute
// URIs (GET http://host/path). The proxy revalidates the URL, then
// forwards the request via a transport that dials the pinned IP.
func (p *pinningProxy) handleForward(w http.ResponseWriter, req *http.Request) {
if req.URL == nil || req.URL.Scheme == "" || req.URL.Host == "" {
http.Error(w, "absolute URL required", http.StatusBadRequest)
return
}
deadline, ok := req.Context().Deadline()
if !ok {
deadline = time.Now().Add(30 * time.Second)
}
decision, err := p.decide(req.Context(), req.URL.String(), p.allowList, p.denyList, deadline)
if err != nil {
if isClientCancellation(req.Context(), err) {
p.logger.DebugContext(req.Context(), fmt.Sprintf("forward abandoned by client for '%s': %s", req.URL, err))
} else {
p.logger.WarnContext(req.Context(), fmt.Sprintf("forward blocked for '%s': %s", req.URL, err))
}
http.Error(w, "request blocked", http.StatusForbidden)
return
}
var proxyURL *url.URL
if p.upstreamProxy != nil {
proxyURL, err = p.upstreamProxy(req.URL)
if err != nil {
p.logger.WarnContext(req.Context(), fmt.Sprintf("resolve upstream proxy for '%s': %s", req.URL.Redacted(), err))
http.Error(w, "upstream proxy error", http.StatusBadGateway)
return
}
}
outReq := req.Clone(req.Context())
outReq.RequestURI = ""
stripHopByHopHeaders(outReq.Header)
// Build a fresh transport per request. The decision contains the pinned
// IPs to dial; reusing a transport across requests would leak the
// decision's closure across unrelated targets.
transport := &http.Transport{
DisableKeepAlives: true,
}
if proxyURL != nil {
// The upstream proxy owns DNS and egress; Go adds Proxy-Authorization
// from the URL's credentials. The decision above already gated the
// destination, and dialBypass dials the proxy host directly.
transport.Proxy = http.ProxyURL(proxyURL)
transport.DialContext = p.dialBypass
} else {
transport.Proxy = nil
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
_, port, splitErr := net.SplitHostPort(addr)
if splitErr != nil {
return nil, fmt.Errorf("split forward addr %q: %w", addr, splitErr)
}
switch {
case decision.Bypass:
return p.dialBypass(ctx, network, addr)
case len(decision.Pinned) > 0:
return p.dialPinned(ctx, network, decision.Pinned, port)
default:
return nil, errors.New("no pinned addresses and not bypassed")
}
}
}
defer transport.CloseIdleConnections()
resp, err := transport.RoundTrip(outReq)
if err != nil {
if isClientCancellation(req.Context(), err) {
p.logger.DebugContext(req.Context(), fmt.Sprintf("forward RoundTrip abandoned by client for '%s': %s", req.URL, err))
} else {
p.logger.WarnContext(req.Context(), fmt.Sprintf("forward RoundTrip failed for '%s': %s", req.URL, err))
}
http.Error(w, "upstream error", http.StatusBadGateway)
return
}
defer resp.Body.Close()
copyHeaders(w.Header(), resp.Header)
stripHopByHopHeaders(w.Header())
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}
// Per RFC 7230 section 6.1.
var hopByHopHeaders = []string{
"Connection",
"Keep-Alive",
"Proxy-Authenticate",
"Proxy-Authorization",
"Proxy-Connection",
"Te",
"Trailer",
"Transfer-Encoding",
"Upgrade",
}
func stripHopByHopHeaders(h http.Header) {
for _, name := range hopByHopHeaders {
h.Del(name)
}
}
func copyHeaders(dst, src http.Header) {
for k, vs := range src {
for _, v := range vs {
dst.Add(k, v)
}
}
}
// isClientCancellation reports whether err originates from the client (for
// example Chromium) closing the connection or letting the request deadline
// pass before the proxy could finish validating the destination. Such
// errors are not policy refusals: the proxy never reached an allow/deny
// rule decision. Callers downgrade these to debug to avoid alarming
// operators with noise from speculative or aborted browser requests. The
// canonical case is a Chromium DNS prefetch that the browser drops before
// the proxy's [outbound.resolveHost] call returns. The [net.DNSError]
// returned by [net.Resolver.LookupNetIP] unwraps to [context.Canceled] or
// [context.DeadlineExceeded] in that case, so an [errors.Is] walk catches
// it.
func isClientCancellation(ctx context.Context, err error) bool {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return true
}
return ctx.Err() != nil
}
// dialThroughUpstreamProxy tunnels to target through the upstream proxy,
// letting [gotenberg.DialThroughProxy] perform the authenticated CONNECT that
// Chromium cannot. dialBypass dials the proxy itself and is overridable in
// tests. See https://github.com/gotenberg/gotenberg/issues/1592.
func (p *pinningProxy) dialThroughUpstreamProxy(ctx context.Context, proxyURL *url.URL, target string) (net.Conn, error) {
return gotenberg.DialThroughProxy(ctx, proxyURL, target, p.dialBypass)
}

View File

@@ -0,0 +1,900 @@
package chromium
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/dlclark/regexp2"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// recordingHandler is a slog.Handler that captures every record emitted
// through it so tests can assert on the level and message of proxy logs.
type recordingHandler struct {
mu sync.Mutex
records []slog.Record
}
func (h *recordingHandler) Enabled(_ context.Context, _ slog.Level) bool { return true }
func (h *recordingHandler) Handle(_ context.Context, r slog.Record) error {
h.mu.Lock()
defer h.mu.Unlock()
h.records = append(h.records, r.Clone())
return nil
}
func (h *recordingHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h }
func (h *recordingHandler) WithGroup(_ string) slog.Handler { return h }
func (h *recordingHandler) snapshot() []slog.Record {
h.mu.Lock()
defer h.mu.Unlock()
out := make([]slog.Record, len(h.records))
copy(out, h.records)
return out
}
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func mustParseURL(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse %q: %v", raw, err)
}
return u
}
// newRawTCPServer starts a TCP server on 127.0.0.1:0 that calls handle for
// every accepted connection. It returns the listener address and a cleanup
// function.
func newRawTCPServer(t *testing.T, handle func(net.Conn)) (string, func()) {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
go func() {
for {
conn, err := l.Accept()
if err != nil {
return
}
go handle(conn)
}
}()
return l.Addr().String(), func() { _ = l.Close() }
}
// newProxyForTest returns a pinning proxy whose decide and dial functions
// are set to test stubs. The proxy is started on a loopback ephemeral
// port and stopped during test cleanup.
func newProxyForTest(t *testing.T, p *pinningProxy) string {
t.Helper()
err := p.Start(testLogger())
if err != nil {
t.Fatalf("start pinning proxy: %v", err)
}
t.Cleanup(func() {
_ = p.Stop(testLogger())
})
return p.URL()
}
func TestPinningProxy_Forward_Pinned_Success(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Host != "example.com" {
t.Errorf("upstream expected Host=example.com, got %q", r.Host)
}
_, _ = fmt.Fprint(w, "hello-from-upstream")
}))
t.Cleanup(upstream.Close)
upstreamURL := mustParseURL(t, upstream.URL)
var decideCalls atomic.Int32
p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
decideCalls.Add(1)
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
}
p.dialPinned = func(ctx context.Context, network string, _ []netip.Addr, _ string) (net.Conn, error) {
return net.Dial(network, upstreamURL.Host)
}
proxyURL := newProxyForTest(t, p)
client := &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(mustParseURL(t, proxyURL)),
},
Timeout: 5 * time.Second,
}
resp, err := client.Get("http://example.com/")
if err != nil {
t.Fatalf("GET via proxy: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if string(body) != "hello-from-upstream" {
t.Fatalf("body = %q, want %q", body, "hello-from-upstream")
}
if got := decideCalls.Load(); got != 1 {
t.Fatalf("decide called %d times, want 1", got)
}
}
func TestPinningProxy_Forward_BlockedByDecide(t *testing.T) {
p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered)
}
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
t.Fatal("dialPinned must not be called when decide returns an error")
return nil, errors.New("unreachable")
}
proxyURL := newProxyForTest(t, p)
client := &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(mustParseURL(t, proxyURL)),
},
Timeout: 5 * time.Second,
}
resp, err := client.Get("http://blocked.example/")
if err != nil {
t.Fatalf("GET via proxy: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %d, want 403", resp.StatusCode)
}
}
func TestPinningProxy_Forward_Bypass(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, "bypassed")
}))
t.Cleanup(upstream.Close)
upstreamURL := mustParseURL(t, upstream.URL)
var bypassCalls atomic.Int32
p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{Bypass: true}, nil
}
p.dialBypass = func(_ context.Context, network, _ string) (net.Conn, error) {
bypassCalls.Add(1)
return net.Dial(network, upstreamURL.Host)
}
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
t.Fatal("dialPinned must not be called on bypass")
return nil, errors.New("unreachable")
}
proxyURL := newProxyForTest(t, p)
client := &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(mustParseURL(t, proxyURL)),
},
Timeout: 5 * time.Second,
}
resp, err := client.Get("http://internal.example/")
if err != nil {
t.Fatalf("GET via proxy: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if got := bypassCalls.Load(); got != 1 {
t.Fatalf("dialBypass called %d times, want 1", got)
}
}
func TestPinningProxy_Forward_StripsHopByHopHeaders(t *testing.T) {
var upstreamSawProxyAuth bool
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Proxy-Authorization") != "" {
upstreamSawProxyAuth = true
}
w.Header().Set("Connection", "close")
w.Header().Set("Proxy-Connection", "close")
w.Header().Set("X-Downstream", "ok")
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(upstream.Close)
upstreamURL := mustParseURL(t, upstream.URL)
p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
}
p.dialPinned = func(ctx context.Context, network string, _ []netip.Addr, _ string) (net.Conn, error) {
return net.Dial(network, upstreamURL.Host)
}
proxyURL := newProxyForTest(t, p)
req, err := http.NewRequest(http.MethodGet, "http://example.com/", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Proxy-Authorization", "Basic Zm9vOmJhcg==")
client := &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(mustParseURL(t, proxyURL)),
},
Timeout: 5 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("GET via proxy: %v", err)
}
defer resp.Body.Close()
if upstreamSawProxyAuth {
t.Fatalf("upstream received Proxy-Authorization, proxy did not strip it")
}
if resp.Header.Get("Proxy-Connection") != "" {
t.Fatalf("response retained Proxy-Connection, proxy did not strip it")
}
if resp.Header.Get("X-Downstream") != "ok" {
t.Fatalf("response missing X-Downstream header")
}
}
func TestPinningProxy_Forward_RejectsNonAbsoluteURL(t *testing.T) {
p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
t.Fatal("decide must not be called for malformed proxy request")
return gotenberg.OutboundDecision{}, nil
}
proxyURL := newProxyForTest(t, p)
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
if err != nil {
t.Fatalf("dial proxy: %v", err)
}
defer conn.Close()
// Send a request with a path-only target, not an absolute URI, which
// the proxy should reject with 400.
_, err = fmt.Fprint(conn, "GET /path HTTP/1.1\r\nHost: example.com\r\n\r\n")
if err != nil {
t.Fatalf("write request: %v", err)
}
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
t.Fatalf("read response: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestPinningProxy_CONNECT_Pinned_Success(t *testing.T) {
upstreamAddr, stop := newRawTCPServer(t, func(c net.Conn) {
defer c.Close()
_, _ = c.Write([]byte("HI"))
buf := make([]byte, 4)
n, _ := io.ReadFull(c, buf)
_, _ = c.Write(buf[:n])
})
t.Cleanup(stop)
var decideCalls atomic.Int32
p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
decideCalls.Add(1)
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
}
p.dialPinned = func(_ context.Context, network string, _ []netip.Addr, _ string) (net.Conn, error) {
return net.Dial(network, upstreamAddr)
}
proxyURL := newProxyForTest(t, p)
// Connect to the proxy, send CONNECT, splice raw bytes.
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
if err != nil {
t.Fatalf("dial proxy: %v", err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
_, err = fmt.Fprintf(conn, "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n")
if err != nil {
t.Fatalf("write CONNECT: %v", err)
}
br := bufio.NewReader(conn)
statusLine, err := br.ReadString('\n')
if err != nil {
t.Fatalf("read status: %v", err)
}
if !strings.Contains(statusLine, " 200 ") {
t.Fatalf("CONNECT status = %q, want 200", statusLine)
}
// Consume the blank line after headers.
for {
line, err := br.ReadString('\n')
if err != nil {
t.Fatalf("read headers: %v", err)
}
if line == "\r\n" || line == "\n" {
break
}
}
hi := make([]byte, 2)
_, err = io.ReadFull(br, hi)
if err != nil {
t.Fatalf("read greeting: %v", err)
}
if string(hi) != "HI" {
t.Fatalf("greeting = %q, want HI", hi)
}
_, err = conn.Write([]byte("PONG"))
if err != nil {
t.Fatalf("write PONG: %v", err)
}
echo := make([]byte, 4)
_, err = io.ReadFull(br, echo)
if err != nil {
t.Fatalf("read echo: %v", err)
}
if string(echo) != "PONG" {
t.Fatalf("echo = %q, want PONG", echo)
}
if got := decideCalls.Load(); got != 1 {
t.Fatalf("decide called %d times, want 1", got)
}
}
func TestPinningProxy_CONNECT_BlockedByDecide(t *testing.T) {
p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered)
}
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
t.Fatal("dialPinned must not be called when decide returns an error")
return nil, errors.New("unreachable")
}
proxyURL := newProxyForTest(t, p)
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
if err != nil {
t.Fatalf("dial proxy: %v", err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
_, err = fmt.Fprintf(conn, "CONNECT rebind.example:443 HTTP/1.1\r\nHost: rebind.example:443\r\n\r\n")
if err != nil {
t.Fatalf("write CONNECT: %v", err)
}
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
t.Fatalf("read response: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("CONNECT status = %d, want 403", resp.StatusCode)
}
}
// TestPinningProxy_DNSRebind_SingleResolution is the regression test for
// the DNS rebinding window. It simulates a DNS authority that returns a
// public IP on the first lookup and a loopback IP on subsequent lookups.
// The proxy must resolve the host exactly once per request and dial the
// IP validated at that moment, so that a second resolution by any later
// layer cannot pivot the connection to an internal target.
func TestPinningProxy_DNSRebind_SingleResolution(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, "public-upstream")
}))
t.Cleanup(upstream.Close)
upstreamURL := mustParseURL(t, upstream.URL)
var lookupCount atomic.Int32
stubDecide := func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
n := lookupCount.Add(1)
if n == 1 {
// First lookup: returns a public IP, validation passes, the
// proxy pins it for the dial.
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("93.184.216.34")}}, nil
}
// Any subsequent lookup for the same host would return a
// loopback IP. This return value must not influence the dial
// because the proxy must not call decide again for this request.
return gotenberg.OutboundDecision{}, fmt.Errorf("rebind lookup: %w", gotenberg.ErrFiltered)
}
p := newPinningProxy(nil, nil, false, false, false)
p.decide = stubDecide
p.dialPinned = func(_ context.Context, network string, addrs []netip.Addr, _ string) (net.Conn, error) {
if len(addrs) != 1 || addrs[0].String() != "93.184.216.34" {
t.Errorf("dialPinned got addrs %v, want [93.184.216.34]", addrs)
}
return net.Dial(network, upstreamURL.Host)
}
proxyURL := newProxyForTest(t, p)
client := &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(mustParseURL(t, proxyURL)),
},
Timeout: 5 * time.Second,
}
resp, err := client.Get("http://rebind.example/")
if err != nil {
t.Fatalf("GET via proxy: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if string(body) != "public-upstream" {
t.Fatalf("body = %q, want %q", body, "public-upstream")
}
if got := lookupCount.Load(); got != 1 {
t.Fatalf("decide called %d times, want exactly 1 (rebind protection)", got)
}
}
// TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug verifies that
// when decide fails because the request context was canceled or its
// deadline expired (the canonical case is Chromium dropping a speculative
// CONNECT before the proxy finishes resolving the host), the proxy logs
// at debug and not at warn. Policy refusals must still warn; see
// [TestPinningProxy_CONNECT_BlockedByDecide].
func TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug(t *testing.T) {
rec := &recordingHandler{}
p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
// Mimic the wrap chain produced by outbound.resolveHost when the
// DNS lookup is canceled mid-flight by Chromium hanging up.
return gotenberg.OutboundDecision{}, fmt.Errorf("validate '%s' host: resolve %q: lookup %s: %w", "https://www.google.com:443", "www.google.com", "www.google.com", context.Canceled)
}
err := p.Start(slog.New(rec))
if err != nil {
t.Fatalf("start pinning proxy: %v", err)
}
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
proxyURL := p.URL()
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
if err != nil {
t.Fatalf("dial proxy: %v", err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
_, err = fmt.Fprintf(conn, "CONNECT www.google.com:443 HTTP/1.1\r\nHost: www.google.com:443\r\n\r\n")
if err != nil {
t.Fatalf("write CONNECT: %v", err)
}
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
t.Fatalf("read response: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %d, want 403", resp.StatusCode)
}
records := rec.snapshot()
var found bool
for _, r := range records {
if !strings.Contains(r.Message, "www.google.com:443") {
continue
}
found = true
if r.Level != slog.LevelDebug {
t.Fatalf("record level = %v, want Debug; message: %s", r.Level, r.Message)
}
if !strings.Contains(r.Message, "abandoned") {
t.Fatalf("message = %q, want it to mention abandoned", r.Message)
}
}
if !found {
t.Fatal("expected a log record mentioning www.google.com:443, found none")
}
}
// TestPinningProxy_Forward_ClientCancellation_LoggedAtDebug is the
// handleForward equivalent of
// [TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug]. Plain
// HTTP forward requests aborted by the client must also log at debug.
func TestPinningProxy_Forward_ClientCancellation_LoggedAtDebug(t *testing.T) {
rec := &recordingHandler{}
p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{}, fmt.Errorf("validate host: %w", context.DeadlineExceeded)
}
err := p.Start(slog.New(rec))
if err != nil {
t.Fatalf("start pinning proxy: %v", err)
}
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
proxyURL := p.URL()
client := &http.Client{
Transport: &http.Transport{Proxy: http.ProxyURL(mustParseURL(t, proxyURL))},
Timeout: 5 * time.Second,
}
resp, err := client.Get("http://www.google.com/")
if err != nil {
t.Fatalf("GET via proxy: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %d, want 403", resp.StatusCode)
}
records := rec.snapshot()
var found bool
for _, r := range records {
if !strings.Contains(r.Message, "www.google.com") {
continue
}
found = true
if r.Level != slog.LevelDebug {
t.Fatalf("record level = %v, want Debug; message: %s", r.Level, r.Message)
}
if !strings.Contains(r.Message, "abandoned") {
t.Fatalf("message = %q, want it to mention abandoned", r.Message)
}
}
if !found {
t.Fatal("expected a log record mentioning www.google.com, found none")
}
}
// TestPinningProxy_PolicyDenial_LoggedAtWarn protects the existing
// behavior: a deny-list match (or any non-cancellation decide error) must
// still surface at warn level so operators see real refusals.
func TestPinningProxy_PolicyDenial_LoggedAtWarn(t *testing.T) {
rec := &recordingHandler{}
p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{}, fmt.Errorf("denied: %w", gotenberg.ErrFiltered)
}
err := p.Start(slog.New(rec))
if err != nil {
t.Fatalf("start pinning proxy: %v", err)
}
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
proxyURL := p.URL()
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
if err != nil {
t.Fatalf("dial proxy: %v", err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
_, err = fmt.Fprintf(conn, "CONNECT denied.example:443 HTTP/1.1\r\nHost: denied.example:443\r\n\r\n")
if err != nil {
t.Fatalf("write CONNECT: %v", err)
}
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
t.Fatalf("read response: %v", err)
}
defer resp.Body.Close()
records := rec.snapshot()
var found bool
for _, r := range records {
if !strings.Contains(r.Message, "denied.example") {
continue
}
found = true
if r.Level != slog.LevelWarn {
t.Fatalf("record level = %v, want Warn; message: %s", r.Level, r.Message)
}
if !strings.Contains(r.Message, "blocked") {
t.Fatalf("message = %q, want it to mention blocked", r.Message)
}
}
if !found {
t.Fatal("expected a log record mentioning denied.example, found none")
}
}
// TestPinningProxy_CONNECT_DialCancellation_LoggedAtDebug verifies that
// when the upstream dial fails because the request context was canceled
// (typically Chromium dropping a speculative CONNECT before a slow IPv6
// dial completes), the proxy logs at debug rather than warn. Genuine
// dial failures must still warn; see
// [TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn].
func TestPinningProxy_CONNECT_DialCancellation_LoggedAtDebug(t *testing.T) {
rec := &recordingHandler{}
p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
}
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
// Mimic a dial canceled mid-flight by the client hanging up,
// which is what net.Dialer returns when ctx.Err() is Canceled.
return nil, fmt.Errorf("dial tcp [2001:4860:482b:7700::]:443: %w", context.Canceled)
}
err := p.Start(slog.New(rec))
if err != nil {
t.Fatalf("start pinning proxy: %v", err)
}
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
proxyURL := p.URL()
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
if err != nil {
t.Fatalf("dial proxy: %v", err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
_, err = fmt.Fprintf(conn, "CONNECT www.google.com:443 HTTP/1.1\r\nHost: www.google.com:443\r\n\r\n")
if err != nil {
t.Fatalf("write CONNECT: %v", err)
}
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
t.Fatalf("read response: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadGateway {
t.Fatalf("status = %d, want 502", resp.StatusCode)
}
records := rec.snapshot()
var found bool
for _, r := range records {
if !strings.Contains(r.Message, "CONNECT dial") || !strings.Contains(r.Message, "www.google.com:443") {
continue
}
found = true
if r.Level != slog.LevelDebug {
t.Fatalf("record level = %v, want Debug; message: %s", r.Level, r.Message)
}
if !strings.Contains(r.Message, "abandoned") {
t.Fatalf("message = %q, want it to mention abandoned", r.Message)
}
}
if !found {
t.Fatal("expected a log record mentioning CONNECT dial for www.google.com:443, found none")
}
}
// TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn guards the existing
// behavior: a genuine dial failure (host unreachable, refused, etc.)
// must still warn so operators see real problems.
func TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn(t *testing.T) {
rec := &recordingHandler{}
p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
}
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
return nil, errors.New("connection refused")
}
err := p.Start(slog.New(rec))
if err != nil {
t.Fatalf("start pinning proxy: %v", err)
}
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
proxyURL := p.URL()
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
if err != nil {
t.Fatalf("dial proxy: %v", err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
_, err = fmt.Fprintf(conn, "CONNECT real.example:443 HTTP/1.1\r\nHost: real.example:443\r\n\r\n")
if err != nil {
t.Fatalf("write CONNECT: %v", err)
}
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
t.Fatalf("read response: %v", err)
}
defer resp.Body.Close()
records := rec.snapshot()
var found bool
for _, r := range records {
if !strings.Contains(r.Message, "CONNECT dial") || !strings.Contains(r.Message, "real.example") {
continue
}
found = true
if r.Level != slog.LevelWarn {
t.Fatalf("record level = %v, want Warn; message: %s", r.Level, r.Message)
}
if !strings.Contains(r.Message, "failed") {
t.Fatalf("message = %q, want it to mention failed", r.Message)
}
}
if !found {
t.Fatal("expected a log record mentioning CONNECT dial for real.example, found none")
}
}
// TestPinningProxy_Forward_RoundTripCancellation_LoggedAtDebug verifies
// the handleForward dial-cancellation path: when the inner Transport's
// dial returns a canceled error (client hung up mid-dial), the proxy
// logs at debug, not warn. Genuine RoundTrip failures still warn.
func TestPinningProxy_Forward_RoundTripCancellation_LoggedAtDebug(t *testing.T) {
rec := &recordingHandler{}
p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
}
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
return nil, fmt.Errorf("dial tcp [2001:4860::]:80: %w", context.Canceled)
}
err := p.Start(slog.New(rec))
if err != nil {
t.Fatalf("start pinning proxy: %v", err)
}
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
proxyURL := p.URL()
client := &http.Client{
Transport: &http.Transport{Proxy: http.ProxyURL(mustParseURL(t, proxyURL))},
Timeout: 5 * time.Second,
}
resp, err := client.Get("http://www.google.com/")
if err != nil {
t.Fatalf("GET via proxy: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadGateway {
t.Fatalf("status = %d, want 502", resp.StatusCode)
}
records := rec.snapshot()
var found bool
for _, r := range records {
if !strings.Contains(r.Message, "forward RoundTrip") || !strings.Contains(r.Message, "www.google.com") {
continue
}
found = true
if r.Level != slog.LevelDebug {
t.Fatalf("record level = %v, want Debug; message: %s", r.Level, r.Message)
}
if !strings.Contains(r.Message, "abandoned") {
t.Fatalf("message = %q, want it to mention abandoned", r.Message)
}
}
if !found {
t.Fatal("expected a log record mentioning forward RoundTrip for www.google.com, found none")
}
}
func TestIsClientCancellation(t *testing.T) {
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()
for _, tc := range []struct {
name string
ctx context.Context
err error
want bool
}{
{
name: "wrapped context.Canceled",
ctx: context.Background(),
err: fmt.Errorf("validate host: %w", context.Canceled),
want: true,
},
{
name: "wrapped context.DeadlineExceeded",
ctx: context.Background(),
err: fmt.Errorf("validate host: %w", context.DeadlineExceeded),
want: true,
},
{
name: "request context already done",
ctx: canceledCtx,
err: errors.New("some unrelated error"),
want: true,
},
{
name: "policy denial",
ctx: context.Background(),
err: fmt.Errorf("denied: %w", gotenberg.ErrFiltered),
want: false,
},
{
name: "plain dial failure",
ctx: context.Background(),
err: errors.New("connection refused"),
want: false,
},
} {
t.Run(tc.name, func(t *testing.T) {
got := isClientCancellation(tc.ctx, tc.err)
if got != tc.want {
t.Fatalf("isClientCancellation = %v, want %v", got, tc.want)
}
})
}
}
func TestPinningProxy_StartTwice(t *testing.T) {
p := newPinningProxy(nil, nil, false, false, false)
err := p.Start(testLogger())
if err != nil {
t.Fatalf("first Start: %v", err)
}
t.Cleanup(func() { _ = p.Stop(testLogger()) })
err = p.Start(testLogger())
if err == nil {
t.Fatal("second Start: expected error, got nil")
}
}
func TestPinningProxy_StopIdempotent(t *testing.T) {
p := newPinningProxy(nil, nil, false, false, false)
// Stop on a never-started proxy is a no-op.
if err := p.Stop(testLogger()); err != nil {
t.Fatalf("Stop on never-started proxy: %v", err)
}
if err := p.Start(testLogger()); err != nil {
t.Fatalf("Start: %v", err)
}
if err := p.Stop(testLogger()); err != nil {
t.Fatalf("first Stop: %v", err)
}
if err := p.Stop(testLogger()); err != nil {
t.Fatalf("second Stop on stopped proxy: %v", err)
}
}

View File

@@ -0,0 +1,76 @@
package chromium
import (
"context"
"encoding/base64"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"sync/atomic"
"testing"
"time"
"github.com/dlclark/regexp2"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// TestPinningProxy_Forward_ThroughUpstreamProxy verifies that when the
// operator opts into proxy-environment honoring, a plain HTTP request is
// forwarded through the upstream (corporate) proxy with the credentials
// Chromium cannot supply. See https://github.com/gotenberg/gotenberg/issues/1592.
func TestPinningProxy_Forward_ThroughUpstreamProxy(t *testing.T) {
var gotAuth atomic.Value
gotAuth.Store("")
// Stand-in for the corporate proxy: an HTTP server that receives the
// forwarded request and records the injected Proxy-Authorization.
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth.Store(r.Header.Get("Proxy-Authorization"))
_, _ = fmt.Fprint(w, "via-corporate-proxy")
}))
t.Cleanup(upstream.Close)
upstreamURL := mustParseURL(t, upstream.URL)
upstreamURL.User = url.UserPassword("bob", "pw")
p := newPinningProxy(nil, nil, false, false, true)
// Force every destination through our stub upstream proxy.
p.upstreamProxy = func(_ *url.URL) (*url.URL, error) { return upstreamURL, nil }
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
}
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
t.Fatal("dialPinned must not be called when routing through an upstream proxy")
return nil, nil
}
proxyURL := newProxyForTest(t, p)
client := &http.Client{
Transport: &http.Transport{Proxy: http.ProxyURL(mustParseURL(t, proxyURL))},
Timeout: 5 * time.Second,
}
resp, err := client.Get("http://example.com/")
if err != nil {
t.Fatalf("GET via proxy: %v", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if string(body) != "via-corporate-proxy" {
t.Fatalf("body = %q, want via-corporate-proxy", body)
}
wantAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("bob:pw"))
if got := gotAuth.Load().(string); got != wantAuth {
t.Fatalf("upstream proxy saw Proxy-Authorization %q, want %q", got, wantAuth)
}
}

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"html/template"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
@@ -23,6 +24,20 @@ import (
"github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
)
// Bounds on the scoped extra HTTP headers feature. Chromium matches every
// scoped header against every paused sub-resource request, so the total
// matching work is the product of the header count and the sub-resource count.
// These caps bound the factors the client controls; [scopeMatchBudget] bounds
// the product. See https://github.com/gotenberg/gotenberg/issues/1588.
const (
maxExtraHttpHeaders = 64
maxExtraHttpHeaderScopeLength = 1024
// A scope pattern matches against a URL, which takes microseconds for any
// reasonable pattern.
extraHttpHeaderScopeMatchTimeout = 250 * time.Millisecond
)
var sameSiteRegexp = regexp2.MustCompile(
`("sameSite"\s*:\s*")(?i:(lax|strict|none))(")`,
regexp2.None,
@@ -168,6 +183,10 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
return fmt.Errorf("unmarshal extraHttpHeaders: %w", err)
}
if len(headers) > maxExtraHttpHeaders {
return fmt.Errorf("too many headers, got %d, expected at most %d", len(headers), maxExtraHttpHeaders)
}
for k, v := range headers {
var scope string
var valueTokens []string
@@ -197,12 +216,17 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
var scopeRegexp *regexp2.Regexp
if len(scope) > 0 {
if len(scope) > maxExtraHttpHeaderScopeLength {
err = errors.Join(err, fmt.Errorf("scope regex pattern for header '%s' is too long, got %d characters, expected at most %d", k, len(scope), maxExtraHttpHeaderScopeLength))
continue
}
p, errCompile := regexp2.Compile(scope, regexp2.None)
if errCompile != nil {
err = errors.Join(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile))
continue
}
p.MatchTimeout = 5 * time.Second
p.MatchTimeout = extraHttpHeaderScopeMatchTimeout
scopeRegexp = p
}
@@ -340,11 +364,12 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
defaultScreenshotOptions := DefaultScreenshotOptions()
var (
width, height int
clip bool
format string
quality int
optimizeForSpeed bool
width, height int
clip bool
format string
quality int
optimizeForSpeed bool
deviceScaleFactor float64
)
form.
@@ -387,21 +412,51 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
quality = intValue
return nil
}).
Bool("optimizeForSpeed", &optimizeForSpeed, defaultScreenshotOptions.OptimizeForSpeed)
Bool("optimizeForSpeed", &optimizeForSpeed, defaultScreenshotOptions.OptimizeForSpeed).
Float64("deviceScaleFactor", &deviceScaleFactor, defaultScreenshotOptions.DeviceScaleFactor)
screenshotOptions := ScreenshotOptions{
Options: options,
Width: width,
Height: height,
Clip: clip,
Format: format,
Quality: quality,
OptimizeForSpeed: optimizeForSpeed,
Options: options,
Width: width,
Height: height,
Clip: clip,
Format: format,
Quality: quality,
OptimizeForSpeed: optimizeForSpeed,
DeviceScaleFactor: deviceScaleFactor,
}
return form, screenshotOptions
}
// rejectFileScheme returns an HTTP 400 [api] error when rawURL uses the
// file:// scheme. /forms/chromium/convert/url and
// /forms/chromium/screenshot/url accept user-supplied URLs and are
// intended for navigating to remote HTTP(S) resources; allowing file://
// lets a caller reach Chromium's working directory through the default
// deny-list's /tmp/ allowance, which exists only to serve main-page
// HTML/Markdown that the other routes generate. Filter the scheme at the
// route layer where no request-scoped allowedFilePrefixes exists.
func rejectFileScheme(rawURL string) error {
parsed, err := url.Parse(rawURL)
if err != nil {
return api.WrapError(
fmt.Errorf("parse URL: %w", err),
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid URL: %s", err)),
)
}
if strings.EqualFold(parsed.Scheme, "file") {
return api.WrapError(
fmt.Errorf("file:// scheme not allowed on URL route"),
api.NewSentinelHttpError(
http.StatusBadRequest,
"file:// URLs are not accepted on this route. Use the /convert/html or /convert/markdown routes to render local HTML",
),
)
}
return nil
}
// convertUrlRoute returns an [api.Route] which can convert a URL to PDF.
func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return api.Route{
@@ -414,13 +469,15 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
mode := pdfengines.FormDataPdfSplitMode(form, false)
pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
encrypt := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
var url string
err := form.
@@ -430,14 +487,21 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
watermark.Expression = watermarkFile
}
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
stamp.Expression = stampFile
err = rejectFileScheme(url)
if err != nil {
return fmt.Errorf("reject URL scheme: %w", err)
}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages)
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
if err != nil {
return fmt.Errorf("validate watermark: %w", err)
}
err = pdfengines.EnsureStampFile(&stamp, stampFile)
if err != nil {
return fmt.Errorf("validate stamp: %w", err)
}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
if err != nil {
return fmt.Errorf("convert URL to PDF: %w", err)
}
@@ -466,6 +530,11 @@ func screenshotUrlRoute(chromium Api) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
err = rejectFileScheme(url)
if err != nil {
return fmt.Errorf("reject URL scheme: %w", err)
}
err = screenshotUrl(ctx, chromium, url, options)
if err != nil {
return fmt.Errorf("URL screenshot: %w", err)
@@ -489,13 +558,15 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
mode := pdfengines.FormDataPdfSplitMode(form, false)
pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
encrypt := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
var inputPath string
err := form.
@@ -505,16 +576,18 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
watermark.Expression = watermarkFile
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
if err != nil {
return fmt.Errorf("validate watermark: %w", err)
}
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
stamp.Expression = stampFile
err = pdfengines.EnsureStampFile(&stamp, stampFile)
if err != nil {
return fmt.Errorf("validate stamp: %w", err)
}
url := fmt.Sprintf("file://%s", inputPath)
options.AllowedFilePrefixes = []string{ctx.DirPath()}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
if err != nil {
return fmt.Errorf("convert HTML to PDF: %w", err)
}
@@ -568,13 +641,15 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
mode := pdfengines.FormDataPdfSplitMode(form, false)
pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
encrypt := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
var (
inputPath string
@@ -589,11 +664,13 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
watermark.Expression = watermarkFile
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
if err != nil {
return fmt.Errorf("validate watermark: %w", err)
}
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
stamp.Expression = stampFile
err = pdfengines.EnsureStampFile(&stamp, stampFile)
if err != nil {
return fmt.Errorf("validate stamp: %w", err)
}
url, err := markdownToHtml(ctx, inputPath, markdownPaths)
@@ -602,7 +679,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
}
options.AllowedFilePrefixes = []string{ctx.DirPath()}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
if err != nil {
return fmt.Errorf("convert markdown to PDF: %w", err)
}
@@ -727,7 +804,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]any, userPassword, ownerPassword string, embedPaths []string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages 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, encrypt gotenberg.EncryptOptions, embedPaths []string, embedsMetadata map[string]map[string]string, facturX gotenberg.FacturX, facturxXmlPath string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error {
outputPath := ctx.GeneratePath(".pdf")
// See https://github.com/gotenberg/gotenberg/issues/1130.
filename := ctx.OutputFilename(outputPath)
@@ -789,7 +866,17 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
return fmt.Errorf("convert to PDF: %w", err)
}
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
if err != nil {
return err
}
err = pdfengines.ValidatePdfEncryptCompat(encrypt)
if err != nil {
return err
}
err = pdfengines.ValidateFacturXCompat(facturX, facturxXmlPath, pdfFormats)
if err != nil {
return err
}
@@ -814,6 +901,8 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
return fmt.Errorf("rotate PDFs: %w", err)
}
pdfFormats = pdfengines.FacturXPdfFormats(ctx, engine, facturX, pdfFormats, true, nil)
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil {
return fmt.Errorf("convert PDF(s): %w", err)
@@ -831,7 +920,17 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
return fmt.Errorf("embed files into PDFs: %w", err)
}
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
err = pdfengines.EmbedFilesMetadataStub(ctx, engine, embedsMetadata, convertOutputPaths)
if err != nil {
return fmt.Errorf("set embeds metadata: %w", err)
}
err = pdfengines.ApplyFacturXStub(ctx, engine, facturX, facturxXmlPath, convertOutputPaths)
if err != nil {
return fmt.Errorf("apply Factur-X: %w", err)
}
err = pdfengines.EncryptPdfStub(ctx, engine, encrypt, convertOutputPaths)
if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err)
}

View File

@@ -0,0 +1,52 @@
package chromium
import (
"sync/atomic"
"time"
)
// scopeMatchBudgetPerConversion caps the total time a single conversion may
// spend matching scoped extra HTTP header patterns.
//
// The per-pattern MatchTimeout bounds one match, not their number: Chromium
// pauses every sub-resource request, and each paused request is matched against
// every scoped header. Without a shared budget the total is the product of the
// two, both of which the client controls.
// See https://github.com/gotenberg/gotenberg/issues/1588.
const scopeMatchBudgetPerConversion = 5 * time.Second
// scopeMatchBudget is a time allowance shared by every scope match of a
// conversion. It is safe for concurrent use: paused requests are handled on
// their own goroutines.
type scopeMatchBudget struct {
remaining atomic.Int64
}
// newScopeMatchBudget returns a [scopeMatchBudget] allowing d of matching.
func newScopeMatchBudget(d time.Duration) *scopeMatchBudget {
b := new(scopeMatchBudget)
b.remaining.Store(int64(d))
return b
}
// tryAcquire reports whether the budget still allows a match.
func (b *scopeMatchBudget) tryAcquire() bool {
return b.remaining.Load() > 0
}
// consume subtracts the time a match took. It saturates at zero so that a long
// match cannot wrap the counter back into credit.
func (b *scopeMatchBudget) consume(d time.Duration) {
for {
current := b.remaining.Load()
if current <= 0 {
return
}
next := max(current-int64(d), 0)
if b.remaining.CompareAndSwap(current, next) {
return
}
}
}

View File

@@ -0,0 +1,122 @@
package chromium
import (
"strings"
"sync"
"testing"
"time"
"github.com/dlclark/regexp2"
)
func TestScopeMatchBudget(t *testing.T) {
t.Run("allows matching while credit remains", func(t *testing.T) {
b := newScopeMatchBudget(time.Second)
if !b.tryAcquire() {
t.Fatal("tryAcquire() = false on a fresh budget, want true")
}
})
t.Run("denies matching once exhausted", func(t *testing.T) {
b := newScopeMatchBudget(time.Second)
b.consume(time.Second)
if b.tryAcquire() {
t.Error("tryAcquire() = true after the budget was spent, want false")
}
})
t.Run("saturates at zero instead of wrapping into credit", func(t *testing.T) {
b := newScopeMatchBudget(time.Second)
b.consume(time.Hour)
if got := b.remaining.Load(); got != 0 {
t.Errorf("remaining = %d, want 0", got)
}
if b.tryAcquire() {
t.Error("tryAcquire() = true after an overlong match, want false")
}
})
t.Run("a spent budget stays spent", func(t *testing.T) {
b := newScopeMatchBudget(time.Second)
b.consume(time.Second)
b.consume(time.Millisecond)
if got := b.remaining.Load(); got != 0 {
t.Errorf("remaining = %d, want 0", got)
}
})
t.Run("is safe for concurrent use", func(t *testing.T) {
const goroutines = 64
// Each goroutine spends 1ms against a budget of half that many
// milliseconds, so the total spend overshoots it.
b := newScopeMatchBudget(time.Duration(goroutines/2) * time.Millisecond)
var wg sync.WaitGroup
for range goroutines {
wg.Go(func() {
b.tryAcquire()
b.consume(time.Millisecond)
})
}
wg.Wait()
if got := b.remaining.Load(); got != 0 {
t.Errorf("remaining = %d, want 0", got)
}
})
}
// TestScopeMatchBudget_BoundsCatastrophicBacktracking is the regression test for
// the amplification: many scoped headers matched against a hostile URL must cost
// the budget, not a multiple of it.
// See https://github.com/gotenberg/gotenberg/issues/1588.
func TestScopeMatchBudget_BoundsCatastrophicBacktracking(t *testing.T) {
const (
headers = 16
budget = 200 * time.Millisecond
)
// Nested quantifier with no possible match: classic catastrophic
// backtracking.
pattern := compileScopePattern(t, `(a+)+b`)
url := "http://example.com/" + strings.Repeat("a", 40)
b := newScopeMatchBudget(budget)
start := time.Now()
var matched int
for range headers {
if !b.tryAcquire() {
break
}
matchStart := time.Now()
_, _ = pattern.MatchString(url)
b.consume(time.Since(matchStart))
matched++
}
elapsed := time.Since(start)
if matched == headers {
t.Errorf("all %d headers were matched, want the budget to stop matching early", headers)
}
// Each match is separately capped at extraHttpHeaderScopeMatchTimeout, so
// the worst case is the budget plus one final match that started with the
// last of the credit. Generous slack keeps this stable on a loaded CI box.
ceiling := budget + extraHttpHeaderScopeMatchTimeout + time.Second
if elapsed > ceiling {
t.Errorf("matching took %s, want at most %s", elapsed, ceiling)
}
}
func compileScopePattern(t *testing.T, pattern string) *regexp2.Regexp {
t.Helper()
p, err := regexp2.Compile(pattern, regexp2.None)
if err != nil {
t.Fatalf("compile %q: %v", pattern, err)
}
p.MatchTimeout = extraHttpHeaderScopeMatchTimeout
return p
}

View File

@@ -14,104 +14,169 @@ import (
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func printToPdfActionFunc(logger *slog.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc {
// resolvePdfOptions applies the cross-option constraints Chromium imposes
// before printing.
//
// Chromium derives the PDF document outline from the tagged-PDF structure
// tree, so [PdfOptions.GenerateDocumentOutline] produces no outline unless
// tagged PDF is also generated. Requesting an outline therefore implies
// tagged PDF. See https://github.com/gotenberg/gotenberg/issues/1579.
func resolvePdfOptions(options PdfOptions) PdfOptions {
if options.GenerateDocumentOutline {
options.GenerateTaggedPdf = true
}
return options
}
func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc {
return func(ctx context.Context) error {
paperHeight := options.PaperHeight
pageRanges := options.PageRanges
if options.SinglePage {
logger.DebugContext(ctx, "single page PDF")
_, _, _, _, _, cssContentSize, err := page.GetLayoutMetrics().Do(ctx)
if err != nil {
return fmt.Errorf("get layout metrics: %w", err)
}
// There are 96 CSS pixels per inch.
// See https://issues.chromium.org/issues/40267771#comment14.
// We add top and bottom margins so that the content area
// is large enough to fit the entire content.
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
pageRanges = "1" // little dirty hack to avoid leftovers.
if options.GenerateDocumentOutline && !options.GenerateTaggedPdf {
logger.DebugContext(ctx, "document outline requested, enabling tagged PDF because Chromium derives the outline from the structure tree")
}
printToPdf := page.PrintToPDF().
WithTransferMode(page.PrintToPDFTransferModeReturnAsStream).
WithLandscape(options.Landscape).
WithPrintBackground(options.PrintBackground).
WithScale(options.Scale).
WithPaperWidth(options.PaperWidth).
WithPaperHeight(paperHeight).
WithMarginTop(options.MarginTop).
WithMarginBottom(options.MarginBottom).
WithMarginLeft(options.MarginLeft).
WithMarginRight(options.MarginRight).
WithPageRanges(pageRanges).
WithPreferCSSPageSize(options.PreferCssPageSize).
WithGenerateDocumentOutline(options.GenerateDocumentOutline).
// See https://github.com/gotenberg/gotenberg/issues/1210.
WithGenerateTaggedPDF(options.GenerateTaggedPdf)
options = resolvePdfOptions(options)
hasCustomHeaderFooter := options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate ||
options.FooterTemplate != DefaultPdfOptions().FooterTemplate
// ctx is the chromedp task context, derived from context.Background(),
// so the span is started under reqCtx to keep print_to_pdf in the
// conversion trace instead of orphaning it into a new one.
_, span := gotenberg.Tracer().Start(reqCtx, "chromium.print_to_pdf",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(printToPdfAttrs(options)...),
)
defer span.End()
if !hasCustomHeaderFooter {
logger.DebugContext(ctx, "no custom header nor footer")
err := func() error {
paperHeight := options.PaperHeight
pageRanges := options.PageRanges
printToPdf = printToPdf.WithDisplayHeaderFooter(false)
if options.SinglePage {
logger.DebugContext(ctx, "single page PDF")
_, _, _, _, _, cssContentSize, err := page.GetLayoutMetrics().Do(ctx)
if err != nil {
return fmt.Errorf("get layout metrics: %w", err)
}
// There are 96 CSS pixels per inch.
// See https://issues.chromium.org/issues/40267771#comment14.
// We add top and bottom margins so that the content area
// is large enough to fit the entire content.
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
pageRanges = "1" // little dirty hack to avoid leftovers.
}
printToPdf := page.PrintToPDF().
WithTransferMode(page.PrintToPDFTransferModeReturnAsStream).
WithLandscape(options.Landscape).
WithPrintBackground(options.PrintBackground).
WithScale(options.Scale).
WithPaperWidth(options.PaperWidth).
WithPaperHeight(paperHeight).
WithMarginTop(options.MarginTop).
WithMarginBottom(options.MarginBottom).
WithMarginLeft(options.MarginLeft).
WithMarginRight(options.MarginRight).
WithPageRanges(pageRanges).
WithPreferCSSPageSize(options.PreferCssPageSize).
WithGenerateDocumentOutline(options.GenerateDocumentOutline).
// See https://github.com/gotenberg/gotenberg/issues/1210.
WithGenerateTaggedPDF(options.GenerateTaggedPdf)
hasCustomHeaderFooter := options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate ||
options.FooterTemplate != DefaultPdfOptions().FooterTemplate
if !hasCustomHeaderFooter {
logger.DebugContext(ctx, "no custom header nor footer")
printToPdf = printToPdf.WithDisplayHeaderFooter(false)
} else {
logger.DebugContext(ctx, "with custom header and/or footer")
printToPdf = printToPdf.
WithDisplayHeaderFooter(true).
WithHeaderTemplate(options.HeaderTemplate).
WithFooterTemplate(options.FooterTemplate)
}
logger.DebugContext(ctx, fmt.Sprintf("print to PDF with: %+v", printToPdf))
_, stream, err := printToPdf.Do(ctx)
if err != nil {
return fmt.Errorf("print to PDF: %w", err)
}
reader := &streamReader{
ctx: ctx,
handle: stream,
r: nil,
pos: 0,
eof: false,
}
defer func() {
err = reader.Close()
if err != nil {
logger.ErrorContext(ctx, fmt.Sprintf("close reader: %s", err))
}
}()
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("open output path: %w", err)
}
defer func() {
err = file.Close()
if err != nil {
logger.ErrorContext(ctx, fmt.Sprintf("close output path: %s", err))
}
}()
buffer := bufio.NewReader(reader)
_, err = buffer.WriteTo(file)
if err != nil {
return fmt.Errorf("write result to output path: %w", err)
}
return nil
}()
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
} else {
logger.DebugContext(ctx, "with custom header and/or footer")
printToPdf = printToPdf.
WithDisplayHeaderFooter(true).
WithHeaderTemplate(options.HeaderTemplate).
WithFooterTemplate(options.FooterTemplate)
span.SetStatus(codes.Ok, "")
}
logger.DebugContext(ctx, fmt.Sprintf("print to PDF with: %+v", printToPdf))
return err
}
}
_, stream, err := printToPdf.Do(ctx)
if err != nil {
return fmt.Errorf("print to PDF: %w", err)
}
reader := &streamReader{
ctx: ctx,
handle: stream,
r: nil,
pos: 0,
eof: false,
}
defer func() {
err = reader.Close()
if err != nil {
logger.ErrorContext(ctx, fmt.Sprintf("close reader: %s", err))
}
}()
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("open output path: %w", err)
}
defer func() {
err = file.Close()
if err != nil {
logger.ErrorContext(ctx, fmt.Sprintf("close output path: %s", err))
}
}()
buffer := bufio.NewReader(reader)
_, err = buffer.WriteTo(file)
if err != nil {
return fmt.Errorf("write result to output path: %w", err)
}
return nil
// printToPdfAttrs derives bounded, low-cardinality attributes from the print
// options. Raw header/footer templates and page ranges are reduced to booleans
// to avoid leaking document content and exploding cardinality.
func printToPdfAttrs(options PdfOptions) []attribute.KeyValue {
return []attribute.KeyValue{
attribute.Bool("gotenberg.chromium.print.landscape", options.Landscape),
attribute.Bool("gotenberg.chromium.print.print_background", options.PrintBackground),
attribute.Float64("gotenberg.chromium.print.scale", options.Scale),
attribute.Float64("gotenberg.chromium.print.paper_width", options.PaperWidth),
attribute.Float64("gotenberg.chromium.print.paper_height", options.PaperHeight),
attribute.Bool("gotenberg.chromium.print.single_page", options.SinglePage),
attribute.Bool("gotenberg.chromium.print.prefer_css_page_size", options.PreferCssPageSize),
attribute.Bool("gotenberg.chromium.print.generate_tagged_pdf", options.GenerateTaggedPdf),
attribute.Bool("gotenberg.chromium.print.has_page_ranges", options.PageRanges != ""),
attribute.Bool("gotenberg.chromium.print.has_header", options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate),
attribute.Bool("gotenberg.chromium.print.has_footer", options.FooterTemplate != DefaultPdfOptions().FooterTemplate),
}
}
@@ -164,11 +229,11 @@ func captureScreenshotActionFunc(logger *slog.Logger, outputPath string, options
}
}
func setDeviceMetricsOverride(logger *slog.Logger, width, height int) chromedp.ActionFunc {
func setDeviceMetricsOverride(logger *slog.Logger, width, height int, deviceScaleFactor float64) chromedp.ActionFunc {
return func(ctx context.Context) error {
logger.DebugContext(ctx, "set device metrics override")
err := emulation.SetDeviceMetricsOverride(int64(width), int64(height), 1.0, false).Do(ctx)
err := emulation.SetDeviceMetricsOverride(int64(width), int64(height), deviceScaleFactor, false).Do(ctx)
if err == nil {
return nil
}
@@ -332,11 +397,13 @@ func navigateActionFunc(logger *slog.Logger, url string, skipNetworkIdleEvent, s
return func(ctx context.Context) error {
logger.DebugContext(ctx, fmt.Sprintf("navigate to '%s'", url))
_, _, _, _, err := page.Navigate(url).Do(ctx)
if err != nil {
return fmt.Errorf("navigate to '%s': %w", url, err)
}
// Register lifecycle listeners before issuing Page.navigate. For
// fast loads (typically file:// pages with no external
// sub-resources), DomContentEventFired / LoadEventFired /
// LoadingFinished can fire between Navigate.Do returning and
// runBatch spawning the waiter goroutines. Registering ahead of
// the navigate command closes that race.
// See https://github.com/gotenberg/gotenberg/issues/1561.
waitFunc := []func() error{
waitForEventDomContentEventFired(ctx, logger),
waitForEventLoadEventFired(ctx, logger),
@@ -355,6 +422,11 @@ func navigateActionFunc(logger *slog.Logger, url string, skipNetworkIdleEvent, s
logger.DebugContext(ctx, "skipping network almost idle event")
}
_, _, _, _, err := page.Navigate(url).Do(ctx)
if err != nil {
return fmt.Errorf("navigate to '%s': %w", url, err)
}
err = runBatch(
ctx,
waitFunc...,

View File

@@ -0,0 +1,52 @@
package chromium
import "testing"
func TestResolvePdfOptions(t *testing.T) {
for _, tc := range []struct {
scenario string
generateOutline bool
generateTaggedIn bool
generateTaggedWant bool
}{
{
scenario: "outline requested forces tagged PDF",
generateOutline: true,
generateTaggedIn: false,
generateTaggedWant: true,
},
{
scenario: "outline requested keeps tagged PDF on",
generateOutline: true,
generateTaggedIn: true,
generateTaggedWant: true,
},
{
scenario: "no outline leaves tagged PDF off",
generateOutline: false,
generateTaggedIn: false,
generateTaggedWant: false,
},
{
scenario: "no outline keeps tagged PDF on",
generateOutline: false,
generateTaggedIn: true,
generateTaggedWant: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
options := DefaultPdfOptions()
options.GenerateDocumentOutline = tc.generateOutline
options.GenerateTaggedPdf = tc.generateTaggedIn
got := resolvePdfOptions(options)
if got.GenerateTaggedPdf != tc.generateTaggedWant {
t.Errorf("expected GenerateTaggedPdf=%t, got %t", tc.generateTaggedWant, got.GenerateTaggedPdf)
}
if got.GenerateDocumentOutline != tc.generateOutline {
t.Errorf("expected GenerateDocumentOutline=%t, got %t", tc.generateOutline, got.GenerateDocumentOutline)
}
})
}
}

View File

@@ -0,0 +1,37 @@
package chromium
import (
"os"
"path/filepath"
"testing"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestChromiumDetectVersion(t *testing.T) {
t.Run("prefers the build-time version without executing Chromium", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "chromium"), []byte("Chromium 146.0.7680.80\n"), 0o600); err != nil {
t.Fatalf("write version file: %v", err)
}
t.Setenv(gotenberg.BuildVersionsDirPathEnvVar, dir)
// A bogus binPath would error if executed, so a correct result proves
// the build-time file is used instead of running Chromium.
mod := &Chromium{args: browserArguments{binPath: "/nonexistent/chromium"}}
if got := mod.Debug()["version"]; got != "Chromium 146.0.7680.80" {
t.Errorf("Debug()[version] = %v, want the build-time value", got)
}
})
t.Run("falls back to executing Chromium when no build-time version", func(t *testing.T) {
t.Setenv(gotenberg.BuildVersionsDirPathEnvVar, "")
// With no build-time file and a bogus binPath, the exec fallback runs
// and records its error rather than a build-time value.
mod := &Chromium{args: browserArguments{binPath: "/nonexistent/chromium"}}
if got := mod.Debug()["version"]; got == "Chromium 146.0.7680.80" {
t.Errorf("Debug()[version] = %v, expected the exec fallback", got)
}
})
}

View File

@@ -2,16 +2,18 @@ package exiftool
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"reflect"
"regexp"
"strings"
"sync"
"syscall"
"github.com/barasher/go-exiftool"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
"go.opentelemetry.io/otel/trace"
@@ -23,10 +25,33 @@ func init() {
gotenberg.MustRegisterModule(new(ExifTool))
}
// safeKeyPattern matches legitimate ExifTool tag names: alphanumeric,
// hyphens, underscores, colons, and periods. The first character may not
// be a hyphen, otherwise exiftool would treat the argv entry as a flag
// rather than a tag assignment. Control characters are implicitly
// rejected because the class is ASCII-only.
var safeKeyPattern = regexp.MustCompile(`^[a-zA-Z0-9_.:][a-zA-Z0-9\-_.:]*$`)
// validateMetadataValue rejects metadata values containing NUL, newline,
// or carriage return. NUL terminates C strings and is rejected by
// [exec.Cmd] anyway; newlines and carriage returns are rejected as
// defense in depth against exiftool parsing quirks, even though argv
// invocation is not susceptible to stdin-protocol injection the way
// the previous go-exiftool backend was. The returned error wraps
// [gotenberg.ErrPdfEngineMetadataValueNotSupported] so the API layer
// surfaces it as HTTP 400.
func validateMetadataValue(key, value string) error {
if strings.ContainsAny(value, "\n\r\x00") {
return fmt.Errorf("write PDF metadata with ExifTool: invalid metadata value for key %q (contains control character): %w", key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
}
return nil
}
// systemTags lists ExifTool tags that reflect internal filesystem state
// rather than actual PDF metadata. These are stripped from both read and
// write operations.
// or tool identity rather than actual PDF metadata. Stripped from read
// output before returning to the caller.
var systemTags = []string{
"SourceFile", // Full path exiftool -j always emits first
"FileName", // Reflects UUID-based disk name, not original filename
"Directory", // Leaks internal temp path
"FileSize", // System attribute
@@ -39,22 +64,106 @@ var systemTags = []string{
"Warning", // Extraction warning messages
}
// writeOnlyDerivedTags lists ExifTool tags that are safe to return when
// reading metadata but should not be written back (writing them can break
// PDF/A compliance or cause side effects).
var writeOnlyDerivedTags = []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
// dangerousTags lists ExifTool pseudo-tags that trigger filesystem side
// effects (file rename, move, link creation, permission change). Writes
// containing any of these keys are silently dropped before the argv is
// handed to exiftool. The comparison strips group prefixes (e.g.
// "System:FileName" collapses to "FileName") because exiftool treats
// the prefixed and bare forms identically.
//
// See https://exiftool.org/TagNames/Extra.html.
var dangerousTags = []string{
"FileName", // Writing this triggers a file rename in ExifTool
"Directory", // Writing this triggers a file move in ExifTool
"HardLink", // Writing this creates a hard link in ExifTool
"SymLink", // Writing this creates a symbolic link in ExifTool
"FilePermissions", // Writing this changes the file's permissions
}
// isDangerousTag reports whether key matches one of the [dangerousTags]
// after case-insensitive comparison with any group prefix stripped.
func isDangerousTag(key string) bool {
bare := key
if i := strings.LastIndex(key, ":"); i >= 0 {
bare = key[i+1:]
}
for _, tag := range dangerousTags {
if strings.EqualFold(bare, tag) {
return true
}
}
return false
}
// buildExifToolWriteArgs builds the variadic argv tail for
//
// exiftool -overwrite_original <args> <path>
//
// from a user-supplied metadata map. Dangerous pseudo-tags are silently
// dropped. Invalid keys (empty, leading dash, control characters) and
// values containing NUL or newlines return an error wrapping
// [gotenberg.ErrPdfEngineMetadataValueNotSupported] so the API layer
// replies with HTTP 400. Supported value kinds: string, []string,
// []any of strings, bool, int, int64, float32, float64.
func buildExifToolWriteArgs(metadata map[string]any) ([]string, error) {
var args []string
for key, value := range metadata {
if isDangerousTag(key) {
continue
}
if !safeKeyPattern.MatchString(key) {
return nil, fmt.Errorf("write PDF metadata with ExifTool: invalid metadata key %q: %w", key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
}
switch val := value.(type) {
case string:
if err := validateMetadataValue(key, val); err != nil {
return nil, err
}
args = append(args, fmt.Sprintf("-%s=%s", key, val))
case []string:
for _, s := range val {
if err := validateMetadataValue(key, s); err != nil {
return nil, err
}
args = append(args, fmt.Sprintf("-%s=%s", key, s))
}
case []any:
// See https://github.com/gotenberg/gotenberg/issues/1048.
for _, entry := range val {
s, ok := entry.(string)
if !ok {
return nil, fmt.Errorf("write PDF metadata with ExifTool: unsupported element type %T in []any for key %q: %w", entry, key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
}
if err := validateMetadataValue(key, s); err != nil {
return nil, err
}
args = append(args, fmt.Sprintf("-%s=%s", key, s))
}
case bool:
args = append(args, fmt.Sprintf("-%s=%t", key, val))
case int:
args = append(args, fmt.Sprintf("-%s=%d", key, val))
case int64:
args = append(args, fmt.Sprintf("-%s=%d", key, val))
case float32:
args = append(args, fmt.Sprintf("-%s=%g", key, val))
case float64:
args = append(args, fmt.Sprintf("-%s=%g", key, val))
default:
return nil, fmt.Errorf("write PDF metadata with ExifTool: unsupported type %T for key %q: %w", value, key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
}
}
return args, nil
}
// ExifTool abstracts the CLI tool ExifTool and implements the
// [gotenberg.PdfEngine] interface.
type ExifTool struct {
binPath string
version string
versionOnce sync.Once
}
// Descriptor returns [ExifTool]'s module descriptor.
@@ -89,26 +198,53 @@ func (engine *ExifTool) Validate() error {
// Debug returns additional debug data.
func (engine *ExifTool) Debug() map[string]any {
debug := make(map[string]any)
return map[string]any{"version": engine.detectVersion()}
}
cmd := exec.Command(engine.binPath, "-ver") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
// detectVersion resolves the ExifTool version once, preferring the value
// captured at image build time so it never spawns ExifTool at runtime. It falls
// back to running exiftool -ver for local or non-Docker builds.
func (engine *ExifTool) detectVersion() string {
engine.versionOnce.Do(func() {
if v, ok := gotenberg.BuildVersion("exiftool"); ok {
engine.version = v
return
}
output, err := cmd.Output()
if err != nil {
debug["version"] = err.Error()
return debug
cmd := exec.Command(engine.binPath, "-ver") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
output, err := cmd.Output()
if err != nil {
engine.version = err.Error()
return
}
engine.version = strings.TrimSpace(string(output))
})
return engine.version
}
// spanAttrs returns the client-span attributes for an ExifTool invocation: the
// server address and the ExifTool version, plus any extra attributes. The
// version rides on every span so a trace records which ExifTool ran the
// operation.
func (engine *ExifTool) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue {
attrs := make([]attribute.KeyValue, 0, 2+len(extra))
attrs = append(attrs, semconv.ServerAddress(engine.binPath))
if v := engine.detectVersion(); v != "" {
attrs = append(attrs, attribute.String("gotenberg.exiftool.version", v))
}
debug["version"] = strings.TrimSpace(string(output))
return debug
return append(attrs, extra...)
}
// Merge is not available in this implementation.
func (engine *ExifTool) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Merge",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -122,7 +258,7 @@ func (engine *ExifTool) Merge(ctx context.Context, logger *slog.Logger, inputPat
func (engine *ExifTool) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Split",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -136,7 +272,7 @@ func (engine *ExifTool) Split(ctx context.Context, logger *slog.Logger, mode got
func (engine *ExifTool) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Flatten",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -150,7 +286,7 @@ func (engine *ExifTool) Flatten(ctx context.Context, logger *slog.Logger, inputP
func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Convert",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -160,150 +296,100 @@ func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, format
return err
}
// ReadMetadata extracts the metadata of a given PDF file.
// ReadMetadata extracts the metadata of a given PDF file by invoking
// the exiftool binary with "-j" (JSON output) and parsing the result.
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.ReadMetadata",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
cmd := exec.CommandContext(ctx, engine.binPath, "-j", inputPath) //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
output, err := cmd.Output()
if err != nil {
err = fmt.Errorf("new ExifTool: %w", err)
err = fmt.Errorf("read metadata with ExifTool: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
defer func(exifTool *exiftool.Exiftool) {
err := exifTool.Close()
if err != nil {
logger.ErrorContext(ctx, fmt.Sprintf("close ExifTool: %v", err))
}
}(exifTool)
fileMetadata := exifTool.ExtractMetadata(inputPath)
if fileMetadata[0].Err != nil {
err = fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err)
var files []map[string]any
err = json.Unmarshal(output, &files)
if err != nil {
err = fmt.Errorf("parse ExifTool JSON output: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
if len(files) == 0 {
err = errors.New("ExifTool returned no file entries")
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
metadata := files[0]
// ExifTool records extraction errors as an "Error" key on the file
// entry rather than via a non-zero exit code. Surface that back as a
// Go error before stripping so callers see the real cause.
if msg, ok := metadata["Error"].(string); ok && msg != "" {
err = fmt.Errorf("read metadata with ExifTool: %s", msg)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
// Strip system tags that reflect internal filesystem state (e.g.,
// UUID-based FileName, temp Directory) rather than actual PDF metadata.
for _, tag := range systemTags {
delete(fileMetadata[0].Fields, tag)
delete(metadata, tag)
}
span.SetStatus(codes.Ok, "")
return fileMetadata[0].Fields, nil
return metadata, nil
}
// WriteMetadata writes the metadata into a given PDF file.
// WriteMetadata writes the metadata into a given PDF file by invoking
// the exiftool binary with "-overwrite_original -TAG=VALUE ... path".
// ExifTool preserves tags that are not mentioned in the argv, so the
// write is a merge rather than a rewrite.
func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.WriteMetadata",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
extraArgs, err := buildExifToolWriteArgs(metadata)
if err != nil {
err = fmt.Errorf("new ExifTool: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
defer func(exifTool *exiftool.Exiftool) {
err := exifTool.Close()
if err != nil {
logger.ErrorContext(ctx, fmt.Sprintf("close ExifTool: %v", err))
}
}(exifTool)
if len(extraArgs) == 0 {
// Nothing to write after filtering. Treat as success so the
// caller can move on without a dedicated zero-tag branch.
span.SetStatus(codes.Ok, "")
return nil
}
fileMetadata := exifTool.ExtractMetadata(inputPath)
if fileMetadata[0].Err != nil {
err = fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err)
args := append([]string{"-overwrite_original"}, extraArgs...)
args = append(args, inputPath)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
err = fmt.Errorf("create ExifTool command: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Strip system and derived tags from the existing file metadata so
// they are not written back (which can break PDF/A compliance or
// cause side effects).
for _, tag := range systemTags {
delete(fileMetadata[0].Fields, tag)
}
for _, tag := range writeOnlyDerivedTags {
delete(fileMetadata[0].Fields, tag)
}
// Filter user-supplied metadata to prevent ExifTool pseudo-tags from
// triggering dangerous side effects like file renames, moves, or link
// creation. Comparison is case-insensitive because ExifTool processes
// tag names case-insensitively.
// See https://exiftool.org/TagNames/Extra.html.
dangerousTags := []string{
"FileName", // Writing this triggers a file rename in ExifTool
"Directory", // Writing this triggers a file move in ExifTool
"HardLink", // Writing this creates a hard link in ExifTool
"SymLink", // Writing this creates a symbolic link in ExifTool
}
for key := range metadata {
for _, tag := range dangerousTags {
if strings.EqualFold(key, tag) {
delete(metadata, key)
}
}
}
for key, value := range metadata {
switch val := value.(type) {
case string:
fileMetadata[0].SetString(key, val)
case []string:
fileMetadata[0].SetStrings(key, val)
case []any:
// See https://github.com/gotenberg/gotenberg/issues/1048.
strs := make([]string, len(val))
for i, entry := range val {
if str, ok := entry.(string); ok {
strs[i] = str
continue
}
err = fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeFor[[]any](), gotenberg.ErrPdfEngineMetadataValueNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
fileMetadata[0].SetStrings(key, strs)
case bool:
fileMetadata[0].SetString(key, fmt.Sprintf("%t", val))
case int:
fileMetadata[0].SetInt(key, int64(val))
case int64:
fileMetadata[0].SetInt(key, val)
case float32:
fileMetadata[0].SetFloat(key, float64(val))
case float64:
fileMetadata[0].SetFloat(key, val)
// TODO: support more complex cases, e.g., arrays and nested objects
// (limitations in underlying library).
default:
err = fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeOf(val), gotenberg.ErrPdfEngineMetadataValueNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
}
exifTool.WriteMetadata(fileMetadata)
if fileMetadata[0].Err != nil {
err = fmt.Errorf("write PDF metadata with ExifTool: %w", fileMetadata[0].Err)
exitCode, err := cmd.Exec()
if err != nil {
err = fmt.Errorf("write PDF metadata with ExifTool (exit %d): %w", exitCode, err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
@@ -317,7 +403,7 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *slog.Logger,
func (engine *ExifTool) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.PageCount",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -370,7 +456,7 @@ func (engine *ExifTool) PageCount(ctx context.Context, logger *slog.Logger, inpu
func (engine *ExifTool) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.WriteBookmarks",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -384,7 +470,7 @@ func (engine *ExifTool) WriteBookmarks(ctx context.Context, logger *slog.Logger,
func (engine *ExifTool) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.ReadBookmarks",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -395,10 +481,10 @@ func (engine *ExifTool) ReadBookmarks(ctx context.Context, logger *slog.Logger,
}
// Encrypt is not available in this implementation.
func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Encrypt",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -412,7 +498,7 @@ func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputP
func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.EmbedFiles",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -426,7 +512,7 @@ func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *slog.Logger, fil
func (engine *ExifTool) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Watermark",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -440,7 +526,7 @@ func (engine *ExifTool) Watermark(ctx context.Context, logger *slog.Logger, inpu
func (engine *ExifTool) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Stamp",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -454,7 +540,7 @@ func (engine *ExifTool) Stamp(ctx context.Context, logger *slog.Logger, inputPat
func (engine *ExifTool) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Rotate",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -464,6 +550,21 @@ func (engine *ExifTool) Rotate(ctx context.Context, logger *slog.Logger, inputPa
return err
}
// EmbedFilesMetadata is not available in this implementation.
func (engine *ExifTool) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
return fmt.Errorf("set embeds metadata with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// InjectFacturXXMP is not available in this implementation.
func (engine *ExifTool) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error {
return fmt.Errorf("inject Factur-X XMP with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// ReadPdfAConformance is not available in this implementation.
func (engine *ExifTool) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
return "", "", fmt.Errorf("read PDF/A conformance with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Interface guards.
var (
_ gotenberg.Module = (*ExifTool)(nil)

View File

@@ -0,0 +1,213 @@
package exiftool
import (
"errors"
"slices"
"testing"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestBuildExifToolWriteArgs_String(t *testing.T) {
args, err := buildExifToolWriteArgs(map[string]any{"Title": "sample"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"-Title=sample"}
if !slices.Equal(args, want) {
t.Fatalf("args = %v, want %v", args, want)
}
}
func TestBuildExifToolWriteArgs_StringSlice(t *testing.T) {
args, err := buildExifToolWriteArgs(map[string]any{"Keywords": []string{"first", "second"}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"-Keywords=first", "-Keywords=second"}
if !slices.Equal(args, want) {
t.Fatalf("args = %v, want %v", args, want)
}
}
func TestBuildExifToolWriteArgs_AnySliceOfStrings(t *testing.T) {
args, err := buildExifToolWriteArgs(map[string]any{"Keywords": []any{"a", "b"}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"-Keywords=a", "-Keywords=b"}
if !slices.Equal(args, want) {
t.Fatalf("args = %v, want %v", args, want)
}
}
func TestBuildExifToolWriteArgs_AnySliceMixedRejected(t *testing.T) {
_, err := buildExifToolWriteArgs(map[string]any{"Keywords": []any{"a", 42}})
if !errors.Is(err, gotenberg.ErrPdfEngineMetadataValueNotSupported) {
t.Fatalf("expected ErrPdfEngineMetadataValueNotSupported, got %v", err)
}
}
func TestBuildExifToolWriteArgs_Numbers(t *testing.T) {
for _, tc := range []struct {
name string
in any
want string
}{
{"int", 42, "-K=42"},
{"int64", int64(42), "-K=42"},
{"float32", float32(1.5), "-K=1.5"},
{"float64", 1.7, "-K=1.7"},
} {
t.Run(tc.name, func(t *testing.T) {
args, err := buildExifToolWriteArgs(map[string]any{"K": tc.in})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(args) != 1 || args[0] != tc.want {
t.Fatalf("args = %v, want [%q]", args, tc.want)
}
})
}
}
func TestBuildExifToolWriteArgs_Bool(t *testing.T) {
args, err := buildExifToolWriteArgs(map[string]any{"Marked": true})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"-Marked=true"}
if !slices.Equal(args, want) {
t.Fatalf("args = %v, want %v", args, want)
}
}
func TestBuildExifToolWriteArgs_InvalidKey(t *testing.T) {
for _, key := range []string{
"", // empty
"-rm", // leading dash — would be parsed as a flag
"foo\nbar", // newline
"foo bar", // space
"foo=bar", // contains equals
"weird/char", // slash
} {
t.Run(key, func(t *testing.T) {
_, err := buildExifToolWriteArgs(map[string]any{key: "value"})
if !errors.Is(err, gotenberg.ErrPdfEngineMetadataValueNotSupported) {
t.Fatalf("expected ErrPdfEngineMetadataValueNotSupported for key %q, got %v", key, err)
}
})
}
}
func TestBuildExifToolWriteArgs_ControlCharValue(t *testing.T) {
for _, val := range []string{
"foo\nbar",
"foo\rbar",
"foo\x00bar",
} {
t.Run(val, func(t *testing.T) {
_, err := buildExifToolWriteArgs(map[string]any{"Title": val})
if !errors.Is(err, gotenberg.ErrPdfEngineMetadataValueNotSupported) {
t.Fatalf("expected ErrPdfEngineMetadataValueNotSupported for value %q, got %v", val, err)
}
})
}
}
func TestBuildExifToolWriteArgs_DangerousTagsStripped(t *testing.T) {
// Dangerous tag keys are silently dropped; legitimate keys still pass.
args, err := buildExifToolWriteArgs(map[string]any{
"Author": "legit",
"FileName": "stolen.pdf",
"System:FileName": "stolen.pdf",
"Directory": "/tmp",
"HardLink": "/tmp/link",
"SymLink": "/tmp/link",
"FilePermissions": "777",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !slices.Equal(args, []string{"-Author=legit"}) {
t.Fatalf("args = %v, want [-Author=legit]", args)
}
}
func TestBuildExifToolWriteArgs_DangerousTagsCaseInsensitive(t *testing.T) {
// Case variations are all dropped because exiftool is case-insensitive.
args, err := buildExifToolWriteArgs(map[string]any{
"filename": "x",
"FILENAME": "x",
"System:Filename": "x",
"Title": "keep",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !slices.Equal(args, []string{"-Title=keep"}) {
t.Fatalf("args = %v, want [-Title=keep]", args)
}
}
func TestBuildExifToolWriteArgs_UnsupportedType(t *testing.T) {
_, err := buildExifToolWriteArgs(map[string]any{"K": map[string]any{"nested": "x"}})
if !errors.Is(err, gotenberg.ErrPdfEngineMetadataValueNotSupported) {
t.Fatalf("expected ErrPdfEngineMetadataValueNotSupported, got %v", err)
}
}
func TestBuildExifToolWriteArgs_Empty(t *testing.T) {
args, err := buildExifToolWriteArgs(map[string]any{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(args) != 0 {
t.Fatalf("args = %v, want empty", args)
}
}
func TestIsDangerousTag(t *testing.T) {
for _, tc := range []struct {
key string
want bool
}{
{"FileName", true},
{"filename", true},
{"System:FileName", true},
{"XMP:FileName", true},
{"Directory", true},
{"HardLink", true},
{"SymLink", true},
{"FilePermissions", true},
{"Title", false},
{"Author", false},
{"FileNameExtra", false}, // Suffix must not match.
{"", false},
} {
t.Run(tc.key, func(t *testing.T) {
if got := isDangerousTag(tc.key); got != tc.want {
t.Fatalf("isDangerousTag(%q) = %v, want %v", tc.key, got, tc.want)
}
})
}
}
func TestSafeKeyPattern(t *testing.T) {
// Rejects leading dash to prevent argv-level flag injection.
if safeKeyPattern.MatchString("-injected") {
t.Fatalf("leading-dash key must be rejected")
}
// Accepts common legitimate forms.
for _, k := range []string{"Title", "System:Title", "XMP-pdf:Title", "My_Tag.1"} {
if !safeKeyPattern.MatchString(k) {
t.Fatalf("key %q must be accepted", k)
}
}
// Rejects control characters.
for _, k := range []string{"a\nb", "a\rb", "a\x00b", "a b"} {
if safeKeyPattern.MatchString(k) {
t.Fatalf("control-char key %q must be rejected", k)
}
}
}

View File

@@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"strings"
"sync"
"syscall"
"time"
@@ -32,11 +33,32 @@ var (
// formats option.
ErrInvalidPdfFormats = errors.New("invalid PDF formats")
// ErrUnoException happens when unoconverter returns exit code 5.
// ErrUnoException happens when unoconverter returns exit code 5. That code
// is the residual bucket of unoconverter's catch-all UNO exception handler:
// it covers a malformed page range, a password supplied to a document that
// does not need one, a failure to open the document and a failure to write
// the output alike. It names the exception class that was caught, not a
// cause. See https://github.com/gotenberg/gotenberg/issues/1588.
ErrUnoException = errors.New("uno exception")
// ErrRuntimeException happens when unoconverter returns exit code 6.
ErrRuntimeException = errors.New("uno exception")
// unoconverter's own message for it reads "Office probably died", yet a
// wrong or missing password also surfaces there. Like [ErrUnoException], it
// does not establish who is at fault.
ErrRuntimeException = errors.New("runtime exception")
// ErrIoException happens when unoconverter returns exit code 3. LibreOffice
// could not read the source document.
ErrIoException = errors.New("io exception")
// ErrCannotConvertException happens when unoconverter returns exit code 4.
// LibreOffice read the document but could not convert it to PDF.
ErrCannotConvertException = errors.New("cannot convert exception")
// ErrIllegalArgumentException happens when unoconverter returns exit code
// 8. LibreOffice rejected the source document, usually because its contents
// do not match its extension.
ErrIllegalArgumentException = errors.New("illegal argument exception")
// ErrCoreDumped happens randomly; sometimes a conversion will work as
// expected, and some other time the same conversion will fail.
@@ -53,11 +75,15 @@ type Api struct {
libreOffice libreOffice
supervisor gotenberg.ProcessSupervisor
version string
versionOnce sync.Once
reqsCounter metric.Int64Counter
errsCounter metric.Int64Counter
conversionDurationCounter metric.Float64Histogram
queueWaitDurationCounter metric.Float64Histogram
pdfOutputSizeCounter metric.Int64Histogram
coreDumpedRetriesCounter metric.Int64Counter
}
// Options gathers available options when converting a document to PDF.
@@ -327,6 +353,11 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
fs.Duration("libreoffice-idle-shutdown-timeout", 0, "Shutdown LibreOffice after being idle for the given duration. Set to 0 to disable this feature")
fs.Bool("libreoffice-auto-start", false, "Automatically launch LibreOffice upon initialization if set to true; otherwise, LibreOffice will start at the time of the first conversion")
fs.Duration("libreoffice-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for LibreOffice to start or restart")
fs.StringSlice("libreoffice-allow-list", []string{}, "Set the allowed URLs for LibreOffice outbound fetches (embedded images, linked content) using regular expressions - supports multiple values")
fs.StringSlice("libreoffice-deny-list", []string{}, "Set the denied URLs for LibreOffice outbound fetches using regular expressions - supports multiple values")
fs.Bool("libreoffice-deny-private-ips", false, "Reject LibreOffice outbound URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted documents to mitigate SSRF against internal services")
fs.Bool("libreoffice-deny-public-ips", false, "Reject LibreOffice outbound URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent outbound traffic from leaving a private network")
fs.Bool("libreoffice-enable-environment-proxy", false, "Route LibreOffice outbound fetches through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials")
return fs
}(),
@@ -353,6 +384,13 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
binPath: libreOfficeBinPath,
unoBinPath: unoBinPath,
startTimeout: flags.MustDuration("libreoffice-start-timeout"),
proxyOptions: outboundProxyOptions{
allowList: flags.MustRegexpSlice("libreoffice-allow-list"),
denyList: flags.MustRegexpSlice("libreoffice-deny-list"),
denyPrivateIPs: flags.MustBool("libreoffice-deny-private-ips"),
denyPublicIPs: flags.MustBool("libreoffice-deny-public-ips"),
enableEnvironmentProxy: flags.MustBool("libreoffice-enable-environment-proxy"),
},
}
// Logger.
@@ -360,7 +398,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"), 1, flags.MustDuration("libreoffice-idle-shutdown-timeout"))
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, "libreoffice", a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"), 1, flags.MustDuration("libreoffice-idle-shutdown-timeout"))
// Metrics.
meter := gotenberg.Meter()
@@ -458,6 +496,15 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
return fmt.Errorf("create libreoffice.pdf.output.size histogram: %w", err)
}
a.coreDumpedRetriesCounter, err = meter.Int64Counter(
"libreoffice.conversion.retries.total",
metric.WithDescription("Total number of LibreOffice conversion retries after a core dump"),
metric.WithUnit("{retry}"),
)
if err != nil {
return fmt.Errorf("create libreoffice.conversion.retries.total counter: %w", err)
}
return nil
}
@@ -467,12 +514,19 @@ func (a *Api) Validate() error {
_, statErr := os.Stat(a.args.binPath)
if os.IsNotExist(statErr) {
err = errors.Join(err, fmt.Errorf("LibreOffice binary path does not exist: %w", statErr))
err = errors.Join(err, fmt.Errorf("LibreOffice binary does not exist at %q; check the LIBREOFFICE_BIN_PATH environment variable: %w", a.args.binPath, statErr))
}
_, statErr = os.Stat(a.args.unoBinPath)
if os.IsNotExist(statErr) {
err = errors.Join(err, fmt.Errorf("unoconverter binary path does not exist: %w", statErr))
err = errors.Join(err, fmt.Errorf("unoconverter binary does not exist at %q; check the UNOCONVERTER_BIN_PATH environment variable: %w", a.args.unoBinPath, statErr))
}
if a.args.proxyOptions.enableEnvironmentProxy {
proxyErr := gotenberg.ValidateEnvironmentProxyVariables()
if proxyErr != nil {
err = errors.Join(err, fmt.Errorf("--libreoffice-enable-environment-proxy is set: %w", proxyErr))
}
}
return err
@@ -520,19 +574,46 @@ func (a *Api) Stop(ctx context.Context) error {
// Debug returns additional debug data.
func (a *Api) Debug() map[string]any {
debug := make(map[string]any)
return map[string]any{"version": a.detectVersion()}
}
cmd := exec.Command(a.args.binPath, "--version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
// detectVersion resolves the LibreOffice version once, preferring the value
// captured at image build time so it never spawns LibreOffice at runtime. It
// falls back to running soffice --version for local or non-Docker builds.
func (a *Api) detectVersion() string {
a.versionOnce.Do(func() {
if v, ok := gotenberg.BuildVersion("libreoffice-api"); ok {
a.version = v
return
}
output, err := cmd.Output()
if err != nil {
debug["version"] = err.Error()
return debug
cmd := exec.Command(a.args.binPath, "--version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
output, err := cmd.Output()
if err != nil {
a.version = err.Error()
return
}
a.version = strings.TrimSpace(string(output))
})
return a.version
}
// spanAttrs returns the client-span attributes for a LibreOffice invocation:
// the server address and the LibreOffice version, plus any extra attributes.
// The version rides on every conversion span so a trace records which
// LibreOffice rendered the document.
func (a *Api) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue {
attrs := make([]attribute.KeyValue, 0, 2+len(extra))
attrs = append(attrs, semconv.ServerAddress(a.args.binPath))
if v := a.detectVersion(); v != "" {
attrs = append(attrs, attribute.String("gotenberg.libreoffice.version", v))
}
debug["version"] = strings.TrimSpace(string(output))
return debug
return append(attrs, extra...)
}
// Metrics returns the metrics.
@@ -608,76 +689,126 @@ func (a *Api) LibreOffice() (Uno, error) {
func (a *Api) Pdf(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error {
ctx, span := gotenberg.Tracer().Start(ctx, "libreoffice.Pdf",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(a.args.binPath)),
trace.WithAttributes(a.spanAttrs()...),
)
defer span.End()
start := time.Now()
var conversionStart time.Time
span.SetAttributes(
attribute.Int64("gotenberg.queue.depth_at_arrival", a.supervisor.ReqQueueSize()),
attribute.Int64("gotenberg.conversions_since_last_restart", a.supervisor.ConversionsSinceRestart()),
)
span.SetAttributes(conversionRequestAttributes(inputPath, options)...)
err := a.supervisor.Run(ctx, logger, func() error {
conversionStart = time.Now()
return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options)
})
// ErrCoreDumped happens randomly (https://github.com/gotenberg/gotenberg/issues/639);
// retry the conversion, but cap the retries so a permanently failing
// document cannot loop forever. Each attempt records its own metrics.
const maxCoreDumpedRetries = 10
// Determine status and error reason.
status := "success"
reason := ""
var err error
var reason string
for attempt := 0; ; attempt++ {
start := time.Now()
var conversionStart time.Time
if err != nil {
switch {
case errors.Is(err, context.DeadlineExceeded):
status = "timeout"
reason = "timeout"
case errors.Is(err, context.Canceled):
err = a.supervisor.Run(ctx, logger, func() error {
conversionStart = time.Now()
return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options)
})
// Determine status and error reason.
status := "success"
reason = ""
if err != nil {
status = "error"
reason = "context_cancelled"
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) || errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
status = "error"
reason = "libreoffice_unavailable"
default:
status = "error"
reason = "unknown"
}
}
// Record metrics.
attrs := metric.WithAttributes(attribute.String("status", status))
a.reqsCounter.Add(ctx, 1, attrs)
if reason != "" {
a.errsCounter.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
}
if !conversionStart.IsZero() {
queueWait := conversionStart.Sub(start).Seconds()
a.queueWaitDurationCounter.Record(ctx, queueWait, attrs)
conversionDuration := time.Since(conversionStart).Seconds()
a.conversionDurationCounter.Record(ctx, conversionDuration, attrs)
}
if err == nil {
stat, statErr := os.Stat(outputPath)
if statErr == nil {
a.pdfOutputSizeCounter.Record(ctx, stat.Size(), attrs)
if errors.Is(err, context.DeadlineExceeded) {
status = "timeout"
}
reason = libreofficeErrorType(err)
}
span.SetStatus(codes.Ok, "")
return nil
}
// See https://github.com/gotenberg/gotenberg/issues/639.
if errors.Is(err, ErrCoreDumped) {
logger.DebugContext(ctx, fmt.Sprintf("got a '%s' error, retry conversion", err))
return a.Pdf(ctx, logger, inputPath, outputPath, options)
// Record metrics for this attempt.
attrs := metric.WithAttributes(attribute.String("status", status))
a.reqsCounter.Add(ctx, 1, attrs)
if reason != "" {
a.errsCounter.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
}
if !conversionStart.IsZero() {
queueWait := conversionStart.Sub(start).Seconds()
a.queueWaitDurationCounter.Record(ctx, queueWait, attrs)
conversionDuration := time.Since(conversionStart).Seconds()
a.conversionDurationCounter.Record(ctx, conversionDuration, attrs)
}
if err == nil {
stat, statErr := os.Stat(outputPath)
if statErr == nil {
a.pdfOutputSizeCounter.Record(ctx, stat.Size(), attrs)
span.SetAttributes(attribute.Int64("gotenberg.conversion.output.bytes", stat.Size()))
}
span.SetStatus(codes.Ok, "")
return nil
}
if errors.Is(err, ErrCoreDumped) && attempt < maxCoreDumpedRetries {
logger.DebugContext(ctx, fmt.Sprintf("got a '%s' error, retry conversion (attempt %d)", err, attempt+1))
span.AddEvent("conversion.retry", trace.WithAttributes(
attribute.Int("attempt", attempt+1),
))
a.coreDumpedRetriesCounter.Add(ctx, 1)
continue
}
break
}
gotenberg.SpanErrorType(span, reason)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return fmt.Errorf("supervisor run task: %w", err)
}
// conversionRequestAttributes derives low-cardinality attributes describing the
// requested conversion: the input document size and the requested PDF format
// options.
func conversionRequestAttributes(inputPath string, options Options) []attribute.KeyValue {
attrs := []attribute.KeyValue{
attribute.String("gotenberg.libreoffice.pdf_a", options.PdfFormats.PdfA),
attribute.Bool("gotenberg.libreoffice.pdf_ua", options.PdfFormats.PdfUa),
attribute.Bool("gotenberg.conversion.landscape", options.Landscape),
attribute.Bool("gotenberg.conversion.has_page_ranges", options.PageRanges != ""),
}
if info, err := os.Stat(inputPath); err == nil {
attrs = append(attrs, attribute.Int64("gotenberg.conversion.input.bytes", info.Size()))
}
return attrs
}
// libreofficeErrorType maps a conversion error to LibreOffice's bounded reason
// value, reused as the span error.type. Generic failures fall back to
// [gotenberg.ClassifyError].
func libreofficeErrorType(err error) string {
switch {
case errors.Is(err, ErrInvalidPdfFormats),
errors.Is(err, ErrIoException),
errors.Is(err, ErrCannotConvertException),
errors.Is(err, ErrIllegalArgumentException):
return gotenberg.ErrorTypeInvalidInput
case errors.Is(err, ErrUnoException), errors.Is(err, ErrRuntimeException):
return "libreoffice_exception"
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded), errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
return "libreoffice_unavailable"
default:
return gotenberg.ClassifyError(err)
}
}
// Extensions returns the file extensions available for conversions.
// FIXME: don't care, take all on the route level?
func (a *Api) Extensions() []string {

View File

@@ -0,0 +1,44 @@
package api
import (
"os"
"path/filepath"
"testing"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestConversionRequestAttributes(t *testing.T) {
tmp := filepath.Join(t.TempDir(), "in.docx")
content := []byte("hello world")
if err := os.WriteFile(tmp, content, 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
options := Options{
Landscape: true,
PageRanges: "1-3",
PdfFormats: gotenberg.PdfFormats{PdfA: "PDF/A-2b", PdfUa: true},
}
got := map[string]any{}
for _, kv := range conversionRequestAttributes(tmp, options) {
got[string(kv.Key)] = kv.Value.AsInterface()
}
if got["gotenberg.libreoffice.pdf_a"] != "PDF/A-2b" {
t.Errorf("pdf_a = %v, want PDF/A-2b", got["gotenberg.libreoffice.pdf_a"])
}
if got["gotenberg.libreoffice.pdf_ua"] != true {
t.Errorf("pdf_ua = %v, want true", got["gotenberg.libreoffice.pdf_ua"])
}
if got["gotenberg.conversion.landscape"] != true {
t.Errorf("landscape = %v, want true", got["gotenberg.conversion.landscape"])
}
if got["gotenberg.conversion.has_page_ranges"] != true {
t.Errorf("has_page_ranges = %v, want true", got["gotenberg.conversion.has_page_ranges"])
}
if got["gotenberg.conversion.input.bytes"] != int64(len(content)) {
t.Errorf("input.bytes = %v, want %d", got["gotenberg.conversion.input.bytes"], len(content))
}
}

View File

@@ -0,0 +1,84 @@
package api
import (
"context"
"errors"
"log/slog"
"testing"
"go.opentelemetry.io/otel"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func sumInt64Counter(rm metricdata.ResourceMetrics, name string) int64 {
var total int64
for _, sm := range rm.ScopeMetrics {
for _, m := range sm.Metrics {
if m.Name != name {
continue
}
if sum, ok := m.Data.(metricdata.Sum[int64]); ok {
for _, dp := range sum.DataPoints {
total += dp.Value
}
}
}
}
return total
}
func TestApi_Pdf_CoreDumpedRetryCap(t *testing.T) {
reader := sdkmetric.NewManualReader()
provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
previous := otel.GetMeterProvider()
otel.SetMeterProvider(provider)
t.Cleanup(func() { otel.SetMeterProvider(previous) })
var runCalls int
supervisor := &gotenberg.ProcessSupervisorMock{
RunMock: func(_ context.Context, _ *slog.Logger, _ func() error) error {
runCalls++
return ErrCoreDumped
},
ReqQueueSizeMock: func() int64 { return 0 },
ConversionsSinceRestartMock: func() int64 { return 0 },
}
a := &Api{supervisor: supervisor}
meter := gotenberg.Meter()
a.reqsCounter, _ = meter.Int64Counter("libreoffice.requests.total")
a.errsCounter, _ = meter.Int64Counter("libreoffice.errors.total")
a.conversionDurationCounter, _ = meter.Float64Histogram("libreoffice.conversion.duration")
a.queueWaitDurationCounter, _ = meter.Float64Histogram("libreoffice.queue.wait.duration")
a.pdfOutputSizeCounter, _ = meter.Int64Histogram("libreoffice.pdf.output.size")
a.coreDumpedRetriesCounter, _ = meter.Int64Counter("libreoffice.conversion.retries.total")
err := a.Pdf(context.Background(), slog.New(slog.DiscardHandler), "/nonexistent/in.docx", "/tmp/out.pdf", Options{})
if err == nil {
t.Fatal("expected an error after exhausting the retries")
}
if !errors.Is(err, ErrCoreDumped) {
t.Errorf("expected ErrCoreDumped, got %v", err)
}
// 1 initial attempt + 10 retries.
if runCalls != 11 {
t.Errorf("supervisor.Run called %d times, want 11", runCalls)
}
var rm metricdata.ResourceMetrics
if err := reader.Collect(context.Background(), &rm); err != nil {
t.Fatalf("collect: %v", err)
}
if retries := sumInt64Counter(rm, "libreoffice.conversion.retries.total"); retries != 10 {
t.Errorf("retries counter = %d, want 10", retries)
}
// Per-attempt request metric must be preserved: one per attempt.
if reqs := sumInt64Counter(rm, "libreoffice.requests.total"); reqs != 11 {
t.Errorf("requests counter = %d, want 11", reqs)
}
}

View File

@@ -0,0 +1,36 @@
package api
import (
"context"
"errors"
"testing"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestLibreofficeErrorType(t *testing.T) {
for _, tc := range []struct {
name string
err error
want string
}{
{"deadline", context.DeadlineExceeded, "timeout"},
{"canceled", context.Canceled, "context_cancelled"},
{"invalid pdf formats", ErrInvalidPdfFormats, "invalid_input"},
{"io exception", ErrIoException, "invalid_input"},
{"cannot convert exception", ErrCannotConvertException, "invalid_input"},
{"illegal argument exception", ErrIllegalArgumentException, "invalid_input"},
{"uno exception", ErrUnoException, "libreoffice_exception"},
{"runtime exception", ErrRuntimeException, "libreoffice_exception"},
{"queue size exceeded", gotenberg.ErrMaximumQueueSizeExceeded, "libreoffice_unavailable"},
{"process restarting", gotenberg.ErrProcessAlreadyRestarting, "libreoffice_unavailable"},
{"core dumped", ErrCoreDumped, "unknown"},
{"unknown", errors.New("boom"), "unknown"},
} {
t.Run(tc.name, func(t *testing.T) {
if got := libreofficeErrorType(tc.err); got != tc.want {
t.Errorf("libreofficeErrorType(%v) = %q, want %q", tc.err, got, tc.want)
}
})
}
}

View File

@@ -7,6 +7,7 @@ import (
"log/slog"
"net"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
@@ -24,12 +25,14 @@ type libreOfficeArguments struct {
binPath string
unoBinPath string
startTimeout time.Duration
proxyOptions outboundProxyOptions
}
type libreOfficeProcess struct {
socketPort int
userProfileDirPath string
cmd *gotenberg.Cmd
proxy *libreOfficeProxy
cfgMu sync.RWMutex
isStarted atomic.Bool
@@ -57,7 +60,26 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
return fmt.Errorf("get free port: %w", err)
}
proxy, err := newLibreOfficeProxy(logger, p.arguments.proxyOptions)
if err != nil {
return fmt.Errorf("create LibreOffice outbound proxy: %w", err)
}
proxy.Start()
userProfileDirPath := p.fs.NewDirPath()
// LibreOffice fetches external content (OOXML images via
// TargetMode=External, RTF INCLUDEPICTURE, ODT linked images) inside
// its own libcurl. The profile config routes those fetches through the
// in-process proxy so the chromium/webhook SSRF filters apply, and
// blocks content linked from untrusted locations so absolute-path
// (file://) and direct fetches are dropped at the source.
if err := writeSofficeProfileConfig(userProfileDirPath, proxy.Addr()); err != nil {
_ = proxy.Stop(context.Background())
return fmt.Errorf("write soffice profile config: %w", err)
}
sofficeEnv := sofficeProxyEnv(os.Environ(), proxy.Addr())
args := []string{
"--headless",
"--invisible",
@@ -75,13 +97,16 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
cmd, err := gotenberg.CommandContext(ctx, logger, p.arguments.binPath, args...)
if err != nil {
_ = proxy.Stop(context.Background())
return fmt.Errorf("create LibreOffice command: %w", err)
}
cmd.SetEnv(sofficeEnv)
// For whatever reason, LibreOffice requires a first start before being
// able to run as a daemon.
exitCode, err := cmd.Exec()
if err != nil && exitCode != 81 {
_ = proxy.Stop(context.Background())
return fmt.Errorf("execute LibreOffice: %w", err)
}
@@ -89,6 +114,7 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
// Second start (daemon).
cmd = gotenberg.Command(logger, p.arguments.binPath, args...)
cmd.SetEnv(sofficeEnv)
err = cmd.Start()
if err != nil {
@@ -139,11 +165,18 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
p.socketPort = port
p.userProfileDirPath = userProfileDirPath
p.cmd = cmd
p.proxy = proxy
p.isStarted.Store(true)
return
}
// LibreOffice failed to start; tear the proxy down too.
stopErr := proxy.Stop(context.Background())
if stopErr != nil {
logger.WarnContext(context.Background(), fmt.Sprintf("stop LibreOffice outbound proxy after failed start: %s", stopErr))
}
// Let's make sure the process is killed.
err = cmd.Kill()
if err != nil {
@@ -165,7 +198,7 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
select {
case err = <-connChan:
if err != nil {
return fmt.Errorf("LibreOffice socket not available: %w", err)
return fmt.Errorf("LibreOffice did not become available within the start timeout; increase --libreoffice-start-timeout or check system resources: %w", err)
}
logger.DebugContext(context.Background(), "LibreOffice socket available")
@@ -173,7 +206,7 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
return nil
case err = <-waitChan:
return fmt.Errorf("LibreOffice process exited: %w", err)
return fmt.Errorf("LibreOffice exited unexpectedly during startup; check system resources such as memory, disk, and permissions: %w", err)
}
}
}
@@ -212,6 +245,16 @@ func (p *libreOfficeProcess) Stop(logger *slog.Logger) error {
return fmt.Errorf("kill LibreOffice process: %w", err)
}
if p.proxy != nil {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
stopErr := p.proxy.Stop(shutdownCtx)
cancel()
if stopErr != nil {
logger.WarnContext(context.Background(), fmt.Sprintf("stop LibreOffice outbound proxy: %s", stopErr))
}
p.proxy = nil
}
p.socketPort = 0
p.userProfileDirPath = ""
p.cmd = nil
@@ -279,6 +322,16 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, input
args = append(args, "--disable-update-indexes")
}
// A CSV becomes a single Calc sheet named after the input file, and Calc's
// default page style prints that sheet name as a centered header. Uploads
// are stored under a UUID-based filename, so the UUID would otherwise leak
// into the rendered PDF. Suppress the header for CSV inputs; spreadsheets
// that carry their own page styles (XLSX, ODS) are left untouched.
// See https://github.com/gotenberg/gotenberg/issues/1568.
if strings.EqualFold(filepath.Ext(inputPath), ".csv") {
args = append(args, "--disable-calc-header")
}
args = append(args, "--export", fmt.Sprintf("ExportFormFields=%t", options.ExportFormFields))
args = append(args, "--export", fmt.Sprintf("AllowDuplicateFieldNames=%t", options.AllowDuplicateFieldNames))
args = append(args, "--export", fmt.Sprintf("ExportBookmarks=%t", options.ExportBookmarks))
@@ -382,9 +435,11 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, input
return nil
}
// LibreOffice's errors are not explicit.
// For instance, exit code 5 may be explained by a malformed page range
// but also by a not required password.
// LibreOffice's errors are not explicit: unoconverter derives its exit code
// from the UNO exception class it caught, not from a diagnosis. Exit codes
// 5 and 6 are ambiguous in particular, so the route decides the HTTP status
// from the request and the document rather than from the code alone.
// See https://github.com/gotenberg/gotenberg/issues/1588.
// We may want to retry in case of a core-dumped event.
// See https://github.com/gotenberg/gotenberg/issues/639.
@@ -392,13 +447,17 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, input
return ErrCoreDumped
}
if exitCode == 5 {
// Potentially malformed page ranges or password not required.
switch exitCode {
case 3:
return ErrIoException
case 4:
return ErrCannotConvertException
case 5:
return ErrUnoException
}
if exitCode == 6 {
// Password potentially required or invalid.
case 6:
return ErrRuntimeException
case 8:
return ErrIllegalArgumentException
}
return fmt.Errorf("convert to PDF: %w", err)

View File

@@ -0,0 +1,129 @@
package api
import (
"archive/zip"
"bytes"
"io"
"os"
"path/filepath"
"strings"
)
// PasswordProtection describes whether a document requires a password to open.
type PasswordProtection int
const (
// PasswordProtectionUnknown means the document's encryption state could not
// be determined.
PasswordProtectionUnknown PasswordProtection = iota
// PasswordProtectionNone means the document opens without a password.
PasswordProtectionNone
// PasswordProtectionRequired means the document is encrypted.
PasswordProtectionRequired
)
var (
// Compound File Binary magic. An encrypted OOXML document is an
// MS-OFFCRYPTO container, which is a compound file. Per MS-CFB 2.2, the
// header signature is fixed.
ole2Magic = []byte{0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1}
// Local file header signature. Per APPNOTE.TXT 4.3.7, every ZIP entry
// starts with it, so an intact package starts with it too.
zipMagic = []byte{0x50, 0x4b, 0x03, 0x04}
// An unencrypted OOXML document is always a ZIP package, so any of these
// extensions over a compound file means the payload is encrypted. Legacy
// binary formats (.doc, .xls, .ppt) are compound files either way and are
// deliberately absent.
ooxmlExtensions = map[string]struct{}{
".docx": {}, ".docm": {}, ".dotx": {}, ".dotm": {},
".xlsx": {}, ".xlsm": {}, ".xltx": {}, ".xltm": {},
".pptx": {}, ".pptm": {}, ".potx": {}, ".potm": {},
".ppsx": {}, ".ppsm": {},
}
)
// odfManifestSizeLimit caps how much of an ODF manifest is read. The manifest
// is a few kilobytes in practice; the cap stops a crafted archive from
// exhausting memory through its decompressed size.
const odfManifestSizeLimit = 1 << 20
// DetectPasswordProtection reports whether the document at path is encrypted.
//
// Detection is advisory and never fails: an unreadable file, an unknown format
// or a malformed archive all yield [PasswordProtectionUnknown]. It exists to
// refine the diagnosis of a conversion that already failed, since LibreOffice's
// exit codes do not distinguish a missing password from a crash.
func DetectPasswordProtection(path string) PasswordProtection {
f, err := os.Open(path)
if err != nil {
return PasswordProtectionUnknown
}
defer func() {
_ = f.Close()
}()
magic := make([]byte, 8)
n, err := io.ReadFull(f, magic)
if err != nil && n < len(zipMagic) {
return PasswordProtectionUnknown
}
magic = magic[:n]
switch {
case bytes.HasPrefix(magic, ole2Magic):
if _, ok := ooxmlExtensions[strings.ToLower(filepath.Ext(path))]; ok {
return PasswordProtectionRequired
}
// A legacy binary document is a compound file whether or not it is
// encrypted; its encryption lives in a stream this cannot cheaply read.
return PasswordProtectionUnknown
case bytes.HasPrefix(magic, zipMagic):
return detectZipPasswordProtection(f)
default:
// Flat XML (.fodt), RTF, CSV and everything else carry no encryption.
return PasswordProtectionUnknown
}
}
// detectZipPasswordProtection inspects a ZIP package. ODF keeps META-INF/manifest.xml
// in cleartext even when encrypted, declaring each encrypted entry. An OOXML
// package has no manifest, and reaching this point already proves it is not an
// MS-OFFCRYPTO container, so it opens without a password.
func detectZipPasswordProtection(f *os.File) PasswordProtection {
size, err := f.Seek(0, io.SeekEnd)
if err != nil {
return PasswordProtectionUnknown
}
r, err := zip.NewReader(f, size)
if err != nil {
return PasswordProtectionUnknown
}
manifest, err := r.Open("META-INF/manifest.xml")
if err != nil {
// No manifest: an OOXML package, or a ZIP that is not an office
// document at all. Neither is encrypted.
return PasswordProtectionNone
}
defer func() {
_ = manifest.Close()
}()
content, err := io.ReadAll(io.LimitReader(manifest, odfManifestSizeLimit))
if err != nil {
return PasswordProtectionUnknown
}
// Per OpenDocument 1.3 part 3, section 4.16, an encrypted entry carries a
// <manifest:encryption-data> child.
if bytes.Contains(content, []byte("encryption-data")) {
return PasswordProtectionRequired
}
return PasswordProtectionNone
}

View File

@@ -0,0 +1,193 @@
package api
import (
"archive/zip"
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
// writeFile writes content to a new file named name inside dir and returns its
// path.
func writeFile(t *testing.T, dir, name string, content []byte) string {
t.Helper()
path := filepath.Join(dir, name)
err := os.WriteFile(path, content, 0o600)
if err != nil {
t.Fatalf("write %s: %v", path, err)
}
return path
}
// writeZip builds a ZIP archive from entries and returns its path.
func writeZip(t *testing.T, dir, name string, entries map[string]string) string {
t.Helper()
buf := new(bytes.Buffer)
w := zip.NewWriter(buf)
for entryName, content := range entries {
f, err := w.Create(entryName)
if err != nil {
t.Fatalf("create zip entry %s: %v", entryName, err)
}
_, err = f.Write([]byte(content))
if err != nil {
t.Fatalf("write zip entry %s: %v", entryName, err)
}
}
err := w.Close()
if err != nil {
t.Fatalf("close zip writer: %v", err)
}
return writeFile(t, dir, name, buf.Bytes())
}
func TestDetectPasswordProtection(t *testing.T) {
dir := t.TempDir()
ole2 := func(name string) string {
return writeFile(t, dir, name, append(ole2Magic, bytes.Repeat([]byte{0x00}, 64)...))
}
for _, tc := range []struct {
name string
path string
want PasswordProtection
}{
{
name: "encrypted OOXML is a compound file",
path: ole2("encrypted.docx"),
want: PasswordProtectionRequired,
},
{
name: "extension casing is ignored",
path: ole2("encrypted.DOCX"),
want: PasswordProtectionRequired,
},
{
name: "encrypted spreadsheet",
path: ole2("encrypted.xlsx"),
want: PasswordProtectionRequired,
},
{
name: "legacy binary document is inconclusive",
path: ole2("legacy.doc"),
want: PasswordProtectionUnknown,
},
{
name: "plain OOXML package",
path: writeZip(t, dir, "plain.docx", map[string]string{
"[Content_Types].xml": "<Types/>",
"word/document.xml": "<w:document/>",
}),
want: PasswordProtectionNone,
},
{
name: "encrypted ODF declares encryption-data in its manifest",
path: writeZip(t, dir, "encrypted.odt", map[string]string{
"mimetype": "application/vnd.oasis.opendocument.text",
"META-INF/manifest.xml": `<manifest:manifest><manifest:file-entry><manifest:encryption-data manifest:checksum="x"/></manifest:file-entry></manifest:manifest>`,
"content.xml": "<office:document-content/>",
}),
want: PasswordProtectionRequired,
},
{
name: "plain ODF has a manifest without encryption-data",
path: writeZip(t, dir, "plain.odt", map[string]string{
"mimetype": "application/vnd.oasis.opendocument.text",
"META-INF/manifest.xml": `<manifest:manifest><manifest:file-entry manifest:full-path="/"/></manifest:manifest>`,
"content.xml": "<office:document-content/>",
}),
want: PasswordProtectionNone,
},
{
name: "flat XML carries no encryption",
path: writeFile(t, dir, "flat.fodt", []byte("<?xml version=\"1.0\"?><office:document/>")),
want: PasswordProtectionUnknown,
},
{
name: "plain text",
path: writeFile(t, dir, "notes.txt", []byte("hello")),
want: PasswordProtectionUnknown,
},
{
name: "file shorter than any magic",
path: writeFile(t, dir, "tiny.docx", []byte{0x50}),
want: PasswordProtectionUnknown,
},
{
name: "empty file",
path: writeFile(t, dir, "empty.docx", nil),
want: PasswordProtectionUnknown,
},
{
name: "truncated archive",
path: writeFile(t, dir, "truncated.docx", append(zipMagic, bytes.Repeat([]byte{0x00}, 32)...)),
want: PasswordProtectionUnknown,
},
{
name: "non-existent path",
path: filepath.Join(dir, "does-not-exist.docx"),
want: PasswordProtectionUnknown,
},
{
name: "directory",
path: dir,
want: PasswordProtectionUnknown,
},
} {
t.Run(tc.name, func(t *testing.T) {
if got := DetectPasswordProtection(tc.path); got != tc.want {
t.Errorf("DetectPasswordProtection(%s) = %d, want %d", tc.path, got, tc.want)
}
})
}
}
// TestDetectPasswordProtection_Fixtures anchors detection to the same documents
// the integration scenarios upload, so a fixture swap cannot silently flip a
// status code.
func TestDetectPasswordProtection_Fixtures(t *testing.T) {
for _, tc := range []struct {
path string
want PasswordProtection
}{
{"../../../../test/integration/testdata/protected_page_1.docx", PasswordProtectionRequired},
{"../../../../test/integration/testdata/page_1.docx", PasswordProtectionNone},
} {
t.Run(filepath.Base(tc.path), func(t *testing.T) {
if _, err := os.Stat(tc.path); err != nil {
t.Skipf("fixture unavailable: %v", err)
}
if got := DetectPasswordProtection(tc.path); got != tc.want {
t.Errorf("DetectPasswordProtection(%s) = %d, want %d", tc.path, got, tc.want)
}
})
}
}
// TestDetectPasswordProtection_OversizedManifest verifies that a manifest far
// larger than the cap still yields a verdict through a bounded read.
func TestDetectPasswordProtection_OversizedManifest(t *testing.T) {
dir := t.TempDir()
// Well past odfManifestSizeLimit, and highly compressible, so the archive
// on disk stays small.
filler := strings.Repeat("<manifest:file-entry manifest:full-path=\"pad\"/>", 200_000)
path := writeZip(t, dir, "oversized.odt", map[string]string{
"mimetype": "application/vnd.oasis.opendocument.text",
"META-INF/manifest.xml": "<manifest:manifest>" + filler + "</manifest:manifest>",
})
if got := DetectPasswordProtection(path); got != PasswordProtectionNone {
t.Errorf("DetectPasswordProtection(oversized) = %d, want %d", got, PasswordProtectionNone)
}
}

View File

@@ -0,0 +1,367 @@
package api
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/dlclark/regexp2"
"golang.org/x/net/http/httpproxy"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// outboundProxyOptions configures a [libreOfficeProxy].
type outboundProxyOptions struct {
allowList []*regexp2.Regexp
denyList []*regexp2.Regexp
denyPrivateIPs bool
denyPublicIPs bool
enableEnvironmentProxy bool
}
// libreOfficeProxy is an HTTP/HTTPS forward proxy that LibreOffice routes
// outbound requests through. Every proxied request goes through
// [gotenberg.DecideOutbound] so the same allow/deny lists and IP-class
// filters that protect chromium and webhook fetches also apply to
// soffice's own libcurl-driven fetches.
//
// soffice triggers an outbound request whenever a document references
// external content (OOXML images via TargetMode="External", RTF
// INCLUDEPICTURE, ODT linked images). Without a filtering proxy in the
// path those fetches bypass every Go-side SSRF guard because they
// originate inside the soffice subprocess.
type libreOfficeProxy struct {
listener net.Listener
server *http.Server
client *http.Client
opts outboundProxyOptions
logger *slog.Logger
// upstreamProxy resolves the upstream (corporate) proxy for a destination
// URL from the standard proxy environment variables, or returns a nil URL
// to connect directly. Nil unless the operator opted into proxy-
// environment honoring. See https://github.com/gotenberg/gotenberg/issues/1592.
upstreamProxy func(*url.URL) (*url.URL, error)
stopOnce sync.Once
}
// newLibreOfficeProxy binds a proxy listener to a free local port and
// applies opts to every proxied request. Callers must call [Start]
// before pointing soffice at the proxy and [Stop] on shutdown.
func newLibreOfficeProxy(logger *slog.Logger, opts outboundProxyOptions) (*libreOfficeProxy, error) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, fmt.Errorf("bind LibreOffice proxy listener: %w", err)
}
decideOpts := []gotenberg.DecideOption{
gotenberg.WithDenyPrivateIPs(opts.denyPrivateIPs),
gotenberg.WithDenyPublicIPs(opts.denyPublicIPs),
}
p := &libreOfficeProxy{
listener: listener,
client: gotenberg.NewOutboundHttpClient(0, opts.allowList, opts.denyList, opts.enableEnvironmentProxy, decideOpts...),
opts: opts,
logger: logger.With(slog.String("logger", "libreoffice-proxy")),
}
if opts.enableEnvironmentProxy {
// Honor the standard proxy environment variables, credentials
// included. httpproxy reads the environment now and applies NO_PROXY.
p.upstreamProxy = httpproxy.FromEnvironment().ProxyFunc()
}
p.server = &http.Server{
Handler: p,
ReadHeaderTimeout: 10 * time.Second,
}
return p, nil
}
// Addr returns the host:port the proxy listens on.
func (p *libreOfficeProxy) Addr() string {
return p.listener.Addr().String()
}
// Start serves proxy requests in a background goroutine until [Stop] is
// called.
func (p *libreOfficeProxy) Start() {
go func() {
err := p.server.Serve(p.listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
p.logger.ErrorContext(context.Background(), fmt.Sprintf("LibreOffice proxy serve: %s", err))
}
}()
}
// Stop gracefully shuts the proxy down. Subsequent calls are no-ops.
func (p *libreOfficeProxy) Stop(ctx context.Context) error {
var err error
p.stopOnce.Do(func() {
err = p.server.Shutdown(ctx)
})
if err != nil {
return fmt.Errorf("shutdown LibreOffice proxy: %w", err)
}
return nil
}
// ServeHTTP dispatches between CONNECT (HTTPS tunnels) and the absolute
// URL form (HTTP forward).
func (p *libreOfficeProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodConnect {
p.handleConnect(w, r)
return
}
p.handleHttp(w, r)
}
// handleHttp forwards a plain HTTP request whose URL line is absolute
// (RFC 7230 5.3.2) through the outbound HTTP client, which validates
// the destination and pins the dial.
func (p *libreOfficeProxy) handleHttp(w http.ResponseWriter, r *http.Request) {
if r.URL == nil || !r.URL.IsAbs() {
http.Error(w, "proxy: expected absolute URI", http.StatusBadRequest)
return
}
outReq := r.Clone(r.Context())
outReq.RequestURI = ""
removeHopByHopHeaders(outReq.Header)
// gosec G704: outReq.URL is exactly what the proxy is here to filter; the
// http.Client returned by NewOutboundHttpClient validates and pins it.
resp, err := p.client.Do(outReq) //nolint:gosec
if err != nil {
p.logger.WarnContext(r.Context(), fmt.Sprintf("LibreOffice proxy rejected forward to '%s': %s", r.URL.String(), err))
http.Error(w, "proxy: destination rejected", http.StatusForbidden)
return
}
defer func() {
closeErr := resp.Body.Close()
if closeErr != nil {
p.logger.DebugContext(r.Context(), fmt.Sprintf("close upstream response body: %s", closeErr))
}
}()
removeHopByHopHeaders(resp.Header)
for key, values := range resp.Header {
for _, value := range values {
w.Header().Add(key, value)
}
}
w.WriteHeader(resp.StatusCode)
_, copyErr := io.Copy(w, resp.Body)
if copyErr != nil {
p.logger.DebugContext(r.Context(), fmt.Sprintf("copy proxied response body: %s", copyErr))
}
}
// handleConnect implements an HTTPS tunnel. It validates the destination
// host through [gotenberg.DecideOutbound] (synthesizing an https URL),
// dials the pinned IPs returned by the decision, and splices bytes
// between client and server.
func (p *libreOfficeProxy) handleConnect(w http.ResponseWriter, r *http.Request) {
host, port, err := net.SplitHostPort(r.Host)
if err != nil {
http.Error(w, "proxy: invalid CONNECT target", http.StatusBadRequest)
return
}
deadline, ok := r.Context().Deadline()
if !ok {
deadline = time.Now().Add(30 * time.Second)
}
rawURL := (&url.URL{Scheme: "https", Host: net.JoinHostPort(host, port)}).String()
decision, err := gotenberg.DecideOutbound(r.Context(), rawURL, p.opts.allowList, p.opts.denyList, deadline,
gotenberg.WithDenyPrivateIPs(p.opts.denyPrivateIPs),
gotenberg.WithDenyPublicIPs(p.opts.denyPublicIPs),
)
if err != nil {
p.logger.WarnContext(r.Context(), fmt.Sprintf("LibreOffice proxy rejected CONNECT to '%s': %s", rawURL, err))
http.Error(w, "proxy: destination rejected", http.StatusForbidden)
return
}
// When the operator routes egress through an authenticated proxy, soffice
// cannot supply the credentials, so the proxy performs the CONNECT (and
// authentication) upstream. The decision above still gated the destination.
var proxyURL *url.URL
if p.upstreamProxy != nil {
proxyURL, err = p.upstreamProxy(&url.URL{Scheme: "https", Host: net.JoinHostPort(host, port)})
if err != nil {
p.logger.WarnContext(r.Context(), fmt.Sprintf("LibreOffice proxy resolve upstream proxy for '%s': %s", rawURL, err))
http.Error(w, "proxy: upstream proxy error", http.StatusBadGateway)
return
}
}
var dest net.Conn
switch {
case proxyURL != nil:
dest, err = gotenberg.DialThroughProxy(r.Context(), proxyURL, r.Host, func(ctx context.Context, network, addr string) (net.Conn, error) {
return net.DialTimeout(network, addr, 10*time.Second)
})
case len(decision.Pinned) > 0:
dest, err = gotenberg.DialPinned(r.Context(), "tcp", decision.Pinned, port)
default:
// Bypass (allow-list match) or non-http-like scheme: dial directly.
// gosec G704: host:port has cleared DecideOutbound above.
dest, err = net.DialTimeout("tcp", net.JoinHostPort(host, port), 10*time.Second) //nolint:gosec
}
if err != nil {
p.logger.WarnContext(r.Context(), fmt.Sprintf("LibreOffice proxy CONNECT dial to '%s' failed: %s", rawURL, err))
http.Error(w, "proxy: dial failed", http.StatusBadGateway)
return
}
hijacker, ok := w.(http.Hijacker)
if !ok {
_ = dest.Close()
http.Error(w, "proxy: hijack unsupported", http.StatusInternalServerError)
return
}
client, _, err := hijacker.Hijack()
if err != nil {
_ = dest.Close()
p.logger.WarnContext(r.Context(), fmt.Sprintf("LibreOffice proxy hijack failed: %s", err))
return
}
_, writeErr := client.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
if writeErr != nil {
_ = client.Close()
_ = dest.Close()
return
}
go pipeAndClose(client, dest)
go pipeAndClose(dest, client)
}
// pipeAndClose copies bytes from src to dst and closes both ends when
// the copy finishes.
func pipeAndClose(dst, src net.Conn) {
defer func() {
_ = dst.Close()
_ = src.Close()
}()
_, _ = io.Copy(dst, src)
}
// hopByHopHeaders is the set of hop-by-hop headers from RFC 7230 6.1
// plus the ones soffice adds when acting as a forward-proxy client.
var hopByHopHeaders = []string{
"Connection",
"Proxy-Connection",
"Keep-Alive",
"Proxy-Authenticate",
"Proxy-Authorization",
"Te",
"Trailer",
"Transfer-Encoding",
"Upgrade",
}
// sofficeProfileConfigTmpl is the registrymodifications.xcu the soffice
// daemon loads at startup. It does two things:
//
// 1. Routes every HTTP and HTTPS fetch through proxyHost:proxyPort so
// soffice's own libcurl fetches hit the in-process SSRF proxy.
// 2. Sets BlockUntrustedRefererLinks so soffice refuses to load content
// linked from a document that sits in an untrusted location.
//
// The second setting closes the local-read and direct-fetch vectors the
// proxy cannot see. A document that links an absolute path
// (file:///etc/...) or any URL is loaded from the per-request temp dir,
// which is never a trusted location, so soffice drops the linked content
// instead of resolving it. Embedded content (stored inside the document)
// is unaffected.
//
// The %s placeholders accept the proxy host and port respectively (host
// first, port second, repeated for HTTP and HTTPS).
const sofficeProfileConfigTmpl = `<?xml version="1.0" encoding="UTF-8"?>
<oor:items xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<item oor:path="/org.openoffice.Office.Common/Security/Scripting"><prop oor:name="BlockUntrustedRefererLinks" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetProxyType" oor:op="fuse"><value>1</value></prop></item>
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetHTTPProxyName" oor:op="fuse"><value>%s</value></prop></item>
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetHTTPProxyPort" oor:op="fuse"><value>%s</value></prop></item>
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetHTTPSProxyName" oor:op="fuse"><value>%s</value></prop></item>
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetHTTPSProxyPort" oor:op="fuse"><value>%s</value></prop></item>
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetNoProxy" oor:op="fuse"><value></value></prop></item>
</oor:items>
`
// writeSofficeProfileConfig drops a registrymodifications.xcu file into
// userProfileDirPath/user/ that points soffice's UCB layer at proxyAddr
// for both HTTP and HTTPS and blocks linked content from untrusted
// locations. proxyAddr must be a host:port pair.
func writeSofficeProfileConfig(userProfileDirPath, proxyAddr string) error {
host, port, err := net.SplitHostPort(proxyAddr)
if err != nil {
return fmt.Errorf("split proxy address %q: %w", proxyAddr, err)
}
userDir := userProfileDirPath + "/user"
err = os.MkdirAll(userDir, 0o755)
if err != nil {
return fmt.Errorf("create soffice user profile directory: %w", err)
}
body := fmt.Sprintf(sofficeProfileConfigTmpl, host, port, host, port)
err = os.WriteFile(userDir+"/registrymodifications.xcu", []byte(body), 0o600)
if err != nil {
return fmt.Errorf("write registrymodifications.xcu: %w", err)
}
return nil
}
// sofficeProxyEnv overlays http_proxy/https_proxy on env so soffice's
// libcurl path also routes through proxyAddr. The environment variables
// supplement the registrymodifications.xcu config so coverage stays
// intact if soffice upgrades and one of the two paths regresses.
func sofficeProxyEnv(env []string, proxyAddr string) []string {
proxyURL := "http://" + proxyAddr
filtered := env[:0:0]
for _, kv := range env {
switch strings.ToLower(strings.SplitN(kv, "=", 2)[0]) {
case "http_proxy", "https_proxy", "no_proxy":
continue
}
filtered = append(filtered, kv)
}
return append(filtered,
"http_proxy="+proxyURL,
"https_proxy="+proxyURL,
"HTTP_PROXY="+proxyURL,
"HTTPS_PROXY="+proxyURL,
"no_proxy=",
"NO_PROXY=",
)
}
func removeHopByHopHeaders(h http.Header) {
if connection := h.Get("Connection"); connection != "" {
for name := range strings.SplitSeq(connection, ",") {
h.Del(strings.TrimSpace(name))
}
}
for _, name := range hopByHopHeaders {
h.Del(name)
}
}

View File

@@ -0,0 +1,372 @@
package api
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/dlclark/regexp2"
)
func compileRegexes(t *testing.T, patterns ...string) []*regexp2.Regexp {
t.Helper()
out := make([]*regexp2.Regexp, 0, len(patterns))
for _, p := range patterns {
r, err := regexp2.Compile(p, 0)
if err != nil {
t.Fatalf("compile %q: %v", p, err)
}
out = append(out, r)
}
return out
}
func startProxy(t *testing.T, opts outboundProxyOptions) *libreOfficeProxy {
t.Helper()
p, err := newLibreOfficeProxy(slog.New(slog.DiscardHandler), opts)
if err != nil {
t.Fatalf("new proxy: %v", err)
}
p.Start()
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = p.Stop(ctx)
})
return p
}
func TestLibreOfficeProxy_HttpForwardAllowed(t *testing.T) {
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusTeapot)
_, _ = w.Write([]byte("hello"))
}))
defer origin.Close()
p := startProxy(t, outboundProxyOptions{})
proxyURL, _ := url.Parse("http://" + p.Addr())
client := &http.Client{
Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
Timeout: 5 * time.Second,
}
resp, err := client.Get(origin.URL + "/foo")
if err != nil {
t.Fatalf("client.Get: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusTeapot {
t.Fatalf("status: got %d, want %d", resp.StatusCode, http.StatusTeapot)
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "hello" {
t.Fatalf("body: got %q, want %q", body, "hello")
}
}
func TestLibreOfficeProxy_HttpForwardDenyListRejects(t *testing.T) {
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("origin must not be reached")
}))
defer origin.Close()
p := startProxy(t, outboundProxyOptions{
denyList: compileRegexes(t, `.*`),
})
proxyURL, _ := url.Parse("http://" + p.Addr())
client := &http.Client{
Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
Timeout: 5 * time.Second,
}
resp, err := client.Get(origin.URL + "/foo")
if err != nil {
t.Fatalf("client.Get: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status: got %d, want %d", resp.StatusCode, http.StatusForbidden)
}
}
func TestLibreOfficeProxy_HttpForwardDenyPrivateIPsRejects(t *testing.T) {
// httptest binds on 127.0.0.1 (a private IP), so denyPrivateIPs
// must reject the forward.
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("origin must not be reached")
}))
defer origin.Close()
p := startProxy(t, outboundProxyOptions{denyPrivateIPs: true})
proxyURL, _ := url.Parse("http://" + p.Addr())
client := &http.Client{
Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
Timeout: 5 * time.Second,
}
resp, err := client.Get(origin.URL + "/foo")
if err != nil {
t.Fatalf("client.Get: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status: got %d, want %d", resp.StatusCode, http.StatusForbidden)
}
}
func TestLibreOfficeProxy_ConnectTunnelHappyPath(t *testing.T) {
// Bring up a tiny TCP echo server.
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen echo: %v", err)
}
defer listener.Close()
go func() {
conn, acceptErr := listener.Accept()
if acceptErr != nil {
return
}
defer conn.Close()
_, _ = io.Copy(conn, conn)
}()
p := startProxy(t, outboundProxyOptions{})
conn, err := net.DialTimeout("tcp", p.Addr(), 2*time.Second)
if err != nil {
t.Fatalf("dial proxy: %v", err)
}
defer conn.Close()
target := listener.Addr().String()
_, err = fmt.Fprintf(conn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", target, target)
if err != nil {
t.Fatalf("write CONNECT: %v", err)
}
reader := bufio.NewReader(conn)
statusLine, err := reader.ReadString('\n')
if err != nil {
t.Fatalf("read CONNECT response: %v", err)
}
if !strings.Contains(statusLine, "200") {
t.Fatalf("CONNECT status: got %q, want 200", statusLine)
}
// Drain remaining headers.
for {
line, readErr := reader.ReadString('\n')
if readErr != nil {
t.Fatalf("read CONNECT headers: %v", readErr)
}
if line == "\r\n" || line == "\n" {
break
}
}
// Tunnel established. Round-trip a payload through the echo server.
want := "ping"
_, err = conn.Write([]byte(want))
if err != nil {
t.Fatalf("write payload: %v", err)
}
got := make([]byte, len(want))
_, err = io.ReadFull(reader, got)
if err != nil {
t.Fatalf("read echo: %v", err)
}
if string(got) != want {
t.Fatalf("echo: got %q, want %q", got, want)
}
}
func TestLibreOfficeProxy_ConnectDenyListRejects(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer listener.Close()
p := startProxy(t, outboundProxyOptions{denyList: compileRegexes(t, `.*`)})
conn, err := net.DialTimeout("tcp", p.Addr(), 2*time.Second)
if err != nil {
t.Fatalf("dial proxy: %v", err)
}
defer conn.Close()
target := listener.Addr().String()
_, err = fmt.Fprintf(conn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", target, target)
if err != nil {
t.Fatalf("write CONNECT: %v", err)
}
reader := bufio.NewReader(conn)
statusLine, err := reader.ReadString('\n')
if err != nil {
t.Fatalf("read response: %v", err)
}
if !strings.Contains(statusLine, "403") {
t.Fatalf("CONNECT status: got %q, want 403", statusLine)
}
}
func TestLibreOfficeProxy_ConnectDenyPrivateIPsRejects(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer listener.Close()
p := startProxy(t, outboundProxyOptions{denyPrivateIPs: true})
conn, err := net.DialTimeout("tcp", p.Addr(), 2*time.Second)
if err != nil {
t.Fatalf("dial proxy: %v", err)
}
defer conn.Close()
// 127.0.0.1 is a private IP under denyPrivateIPs.
target := listener.Addr().String()
_, err = fmt.Fprintf(conn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", target, target)
if err != nil {
t.Fatalf("write CONNECT: %v", err)
}
reader := bufio.NewReader(conn)
statusLine, err := reader.ReadString('\n')
if err != nil {
t.Fatalf("read response: %v", err)
}
if !strings.Contains(statusLine, "403") {
t.Fatalf("CONNECT status: got %q, want 403", statusLine)
}
}
func TestLibreOfficeProxy_StopIsIdempotent(t *testing.T) {
p, err := newLibreOfficeProxy(slog.New(slog.DiscardHandler), outboundProxyOptions{})
if err != nil {
t.Fatalf("new proxy: %v", err)
}
p.Start()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := p.Stop(ctx); err != nil {
t.Fatalf("first Stop: %v", err)
}
if err := p.Stop(ctx); err != nil {
t.Fatalf("second Stop: %v", err)
}
}
func TestWriteSofficeProfileConfig(t *testing.T) {
dir := t.TempDir()
if err := writeSofficeProfileConfig(dir, "127.0.0.1:9876"); err != nil {
t.Fatalf("writeSofficeProfileConfig: %v", err)
}
body, err := os.ReadFile(filepath.Join(dir, "user", "registrymodifications.xcu"))
if err != nil {
t.Fatalf("read xcu: %v", err)
}
for _, want := range []string{
`ooInetProxyType`, `<value>1</value>`,
`ooInetHTTPProxyName`, `<value>127.0.0.1</value>`,
`ooInetHTTPProxyPort`, `<value>9876</value>`,
`ooInetHTTPSProxyName`, `ooInetHTTPSProxyPort`,
// Blocks linked content from untrusted locations, closing the
// file:// local-read and direct-fetch vectors the proxy cannot see.
`BlockUntrustedRefererLinks`, `<value>true</value>`,
} {
if !strings.Contains(string(body), want) {
t.Errorf("xcu missing %q\nfull body:\n%s", want, body)
}
}
}
func TestWriteSofficeProfileConfig_InvalidAddr(t *testing.T) {
err := writeSofficeProfileConfig(t.TempDir(), "not-a-host-port")
if err == nil {
t.Fatal("expected error for malformed proxy address")
}
if !errors.Is(err, errors.Unwrap(err)) {
// Only checking that an error was returned; underlying error type is
// implementation detail.
_ = err
}
}
func TestSofficeProxyEnv_OverridesExisting(t *testing.T) {
in := []string{
"PATH=/usr/bin",
"http_proxy=http://attacker:1",
"HTTPS_PROXY=http://attacker:1",
"NO_PROXY=internal",
"USER=gotenberg",
}
out := sofficeProxyEnv(in, "127.0.0.1:9876")
want := map[string]string{
"http_proxy": "http://127.0.0.1:9876",
"https_proxy": "http://127.0.0.1:9876",
"HTTP_PROXY": "http://127.0.0.1:9876",
"HTTPS_PROXY": "http://127.0.0.1:9876",
"no_proxy": "",
"NO_PROXY": "",
}
got := map[string]string{}
for _, kv := range out {
parts := strings.SplitN(kv, "=", 2)
got[parts[0]] = parts[1]
}
for key, value := range want {
if got[key] != value {
t.Errorf("env[%s]: got %q, want %q", key, got[key], value)
}
}
// Pre-existing unrelated keys must survive.
if got["PATH"] != "/usr/bin" {
t.Errorf("env[PATH]: got %q, want /usr/bin", got["PATH"])
}
if got["USER"] != "gotenberg" {
t.Errorf("env[USER]: got %q, want gotenberg", got["USER"])
}
// Old proxy values must be gone, not duplicated. Count exact-case keys.
counts := map[string]int{}
for _, kv := range out {
key := strings.SplitN(kv, "=", 2)[0]
counts[key]++
}
for _, key := range []string{"http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY", "no_proxy", "NO_PROXY"} {
if counts[key] != 1 {
t.Errorf("env[%s] count: got %d, want 1", key, counts[key])
}
}
}

View File

@@ -106,7 +106,7 @@ func (engine *LibreOfficePdfEngine) ReadBookmarks(ctx context.Context, logger *s
}
// Encrypt is not available in this implementation.
func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
return fmt.Errorf("encrypt PDF using LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
@@ -115,6 +115,11 @@ func (engine *LibreOfficePdfEngine) EmbedFiles(ctx context.Context, logger *slog
return fmt.Errorf("embed files with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// EmbedFilesMetadata is not available in this implementation.
func (engine *LibreOfficePdfEngine) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
return fmt.Errorf("set embeds metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Watermark is not available in this implementation.
func (engine *LibreOfficePdfEngine) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
return fmt.Errorf("watermark PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
@@ -130,6 +135,16 @@ func (engine *LibreOfficePdfEngine) Rotate(ctx context.Context, logger *slog.Log
return fmt.Errorf("rotate PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// InjectFacturXXMP is not available in this implementation.
func (engine *LibreOfficePdfEngine) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error {
return fmt.Errorf("inject Factur-X XMP with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// ReadPdfAConformance is not available in this implementation.
func (engine *LibreOfficePdfEngine) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
return "", "", fmt.Errorf("read PDF/A conformance with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Interface guards.
var (
_ gotenberg.Module = (*LibreOfficePdfEngine)(nil)

View File

@@ -15,6 +15,11 @@ import (
"github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
)
// unattributableFailureMessage is returned when LibreOffice fails and no
// client-supplied input is implicated. Its only format verb is the original
// filename.
const unattributableFailureMessage = "LibreOffice failed to convert the document '%s'. This is usually a resource issue: increase the container's memory and CPU, or reduce the document's size. The request is valid and may be retried."
// convertRoute returns an [api.Route] which can convert LibreOffice documents
// to PDF.
func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) api.Route {
@@ -30,13 +35,15 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
splitMode := pdfengines.FormDataPdfSplitMode(form, false)
pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
encrypt := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
angle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
zeroValuedSplitMode := gotenberg.SplitMode{}
@@ -303,20 +310,36 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
return fmt.Errorf("validate form data: %w", err)
}
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
watermark.Expression = watermarkFile
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
if err != nil {
return fmt.Errorf("validate watermark: %w", err)
}
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
stamp.Expression = stampFile
err = pdfengines.EnsureStampFile(&stamp, stampFile)
if err != nil {
return fmt.Errorf("validate stamp: %w", err)
}
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
if err != nil {
return err
}
err = pdfengines.ValidatePdfEncryptCompat(encrypt)
if err != nil {
return err
}
err = pdfengines.ValidateFacturXCompat(facturX, facturxXmlPath, pdfFormats)
if err != nil {
return err
}
// Factur-X requires PDF/A-3; default to PDF/A-3b when no format was
// requested. The conversion runs as a post-processing step below.
pdfFormats = pdfengines.FacturXPdfFormats(ctx, engine, facturX, pdfFormats, true, nil)
hasPostProcessing := watermark.Source != "" || stamp.Source != "" || angle != 0 ||
len(embedPaths) > 0 || len(metadata) > 0 || flatten
len(embedPaths) > 0 || len(metadata) > 0 || flatten || facturX.ConformanceLevel != ""
outputPaths := make([]string, len(inputPaths))
for i, inputPath := range inputPaths {
@@ -382,25 +405,57 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("A PDF format in '%+v' is not supported", pdfFormats),
fmt.Sprintf("The PDF format '%s' is not supported. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.", pdfFormats.PdfA),
),
)
}
if errors.Is(err, libreofficeapi.ErrUnoException) {
filename := ctx.OriginalFilename(inputPath)
if errors.Is(err, libreofficeapi.ErrIoException) || errors.Is(err, libreofficeapi.ErrIllegalArgumentException) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice failed to process a document: possible causes include malformed page ranges '%s' (nativePageRanges), or, if a password has been provided, it may not be required. In any case, the exact cause is uncertain.", options.PageRanges)),
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice could not read the document '%s'. Ensure the file is not corrupted and that its extension matches its actual format.", filename)),
)
}
if errors.Is(err, libreofficeapi.ErrRuntimeException) {
if errors.Is(err, libreofficeapi.ErrCannotConvertException) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHttpError(http.StatusBadRequest, "LibreOffice failed to process a document: a password may be required, or, if one has been given, it is invalid. In any case, the exact cause is uncertain."),
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice read the document '%s' but could not convert it to PDF. The document may be corrupted or rely on an unsupported feature.", filename)),
)
}
// Exit codes 5 and 6 name the UNO exception class that was
// caught, not a cause: both cover a client mistake and a
// LibreOffice crash. Blame the client only when one of its
// inputs is actually implicated, since the server is the
// only remaining explanation otherwise. Password evidence
// outranks page ranges: a password failure aborts on import,
// before the export filter applies any page range.
// See https://github.com/gotenberg/gotenberg/issues/1588.
if errors.Is(err, libreofficeapi.ErrUnoException) || errors.Is(err, libreofficeapi.ErrRuntimeException) {
protection := libreofficeapi.DetectPasswordProtection(inputPath)
var sentinel api.SentinelHttpError
switch {
case protection == libreofficeapi.PasswordProtectionRequired && options.Password == "":
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("The document '%s' is password-protected. Provide its password in the 'password' form field.", filename))
case protection == libreofficeapi.PasswordProtectionRequired:
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("The password for the document '%s' is incorrect. Check the 'password' form field.", filename))
case protection == libreofficeapi.PasswordProtectionNone && options.Password != "":
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("The document '%s' is not password-protected. Remove the 'password' form field.", filename))
case options.Password != "":
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice could not open the document '%s' with the given password. Check the 'password' form field, and omit it if the document is not password-protected.", filename))
case errors.Is(err, libreofficeapi.ErrUnoException) && options.PageRanges != "":
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice could not apply the page ranges '%s' to the document '%s'. Check the 'nativePageRanges' form field; valid values look like '1-4', '2' or '1,3,5-7'.", options.PageRanges, filename))
default:
sentinel = api.NewSentinelHttpError(http.StatusInternalServerError, fmt.Sprintf(unattributableFailureMessage, filename))
}
return api.WrapError(fmt.Errorf("convert to PDF: %w", err), sentinel)
}
return fmt.Errorf("convert to PDF: %w", err)
}
}
@@ -495,7 +550,17 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
return fmt.Errorf("embed files into PDFs: %w", err)
}
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
err = pdfengines.EmbedFilesMetadataStub(ctx, engine, embedsMetadata, outputPaths)
if err != nil {
return fmt.Errorf("set embeds metadata: %w", err)
}
err = pdfengines.ApplyFacturXStub(ctx, engine, facturX, facturxXmlPath, outputPaths)
if err != nil {
return fmt.Errorf("apply Factur-X: %w", err)
}
err = pdfengines.EncryptPdfStub(ctx, engine, encrypt, outputPaths)
if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err)
}

View File

@@ -0,0 +1,241 @@
package libreoffice
import (
"archive/zip"
"bytes"
"context"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/labstack/echo/v4"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
)
// compoundFile writes a document whose header marks it as a compound file. Over
// an OOXML extension, that means an encrypted payload.
func compoundFile(t *testing.T, dir, name string) string {
t.Helper()
content := append(
[]byte{0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1},
bytes.Repeat([]byte{0x00}, 64)...,
)
return writeTestFile(t, dir, name, content)
}
// zipPackage writes a minimal, unencrypted OOXML package.
func zipPackage(t *testing.T, dir, name string) string {
t.Helper()
buf := new(bytes.Buffer)
w := zip.NewWriter(buf)
f, err := w.Create("[Content_Types].xml")
if err != nil {
t.Fatalf("create zip entry: %v", err)
}
_, err = f.Write([]byte("<Types/>"))
if err != nil {
t.Fatalf("write zip entry: %v", err)
}
err = w.Close()
if err != nil {
t.Fatalf("close zip writer: %v", err)
}
return writeTestFile(t, dir, name, buf.Bytes())
}
func writeTestFile(t *testing.T, dir, name string, content []byte) string {
t.Helper()
path := filepath.Join(dir, name)
err := os.WriteFile(path, content, 0o600)
if err != nil {
t.Fatalf("write %s: %v", path, err)
}
return path
}
// TestConvertRoute_FailureStatus pins the branch table that decides whether a
// LibreOffice failure is the client's fault. See
// https://github.com/gotenberg/gotenberg/issues/1588.
func TestConvertRoute_FailureStatus(t *testing.T) {
dir := t.TempDir()
var (
protected = compoundFile(t, dir, "protected_page_1.docx")
plain = zipPackage(t, dir, "page_1.docx")
legacy = compoundFile(t, dir, "legacy.doc")
corrupted = writeTestFile(t, dir, "corrupted.docx", []byte("not a document"))
unreachable = filepath.Join(dir, "vanished.docx")
)
for _, tc := range []struct {
name string
inputPath string
values map[string][]string
err error
wantStatus int
wantBody string
}{
{
name: "encrypted document, no password",
inputPath: protected,
err: libreofficeapi.ErrRuntimeException,
wantStatus: http.StatusBadRequest,
wantBody: "The document 'protected_page_1.docx' is password-protected. Provide its password in the 'password' form field.",
},
{
name: "encrypted document, wrong password",
inputPath: protected,
values: map[string][]string{"password": {"bar"}},
err: libreofficeapi.ErrRuntimeException,
wantStatus: http.StatusBadRequest,
wantBody: "The password for the document 'protected_page_1.docx' is incorrect. Check the 'password' form field.",
},
{
name: "unencrypted document, password supplied",
inputPath: plain,
values: map[string][]string{"password": {"foo"}},
err: libreofficeapi.ErrUnoException,
wantStatus: http.StatusBadRequest,
wantBody: "The document 'page_1.docx' is not password-protected. Remove the 'password' form field.",
},
{
name: "inconclusive document, password supplied",
inputPath: legacy,
values: map[string][]string{"password": {"foo"}},
err: libreofficeapi.ErrUnoException,
wantStatus: http.StatusBadRequest,
wantBody: "LibreOffice could not open the document 'legacy.doc' with the given password. Check the 'password' form field, and omit it if the document is not password-protected.",
},
{
name: "malformed page ranges",
inputPath: plain,
values: map[string][]string{"nativePageRanges": {"foo"}},
err: libreofficeapi.ErrUnoException,
wantStatus: http.StatusBadRequest,
wantBody: "LibreOffice could not apply the page ranges 'foo' to the document 'page_1.docx'. Check the 'nativePageRanges' form field; valid values look like '1-4', '2' or '1,3,5-7'.",
},
{
name: "password evidence outranks page ranges",
inputPath: protected,
values: map[string][]string{"nativePageRanges": {"1-2"}},
err: libreofficeapi.ErrUnoException,
wantStatus: http.StatusBadRequest,
wantBody: "The document 'protected_page_1.docx' is password-protected. Provide its password in the 'password' form field.",
},
{
name: "page ranges do not excuse a runtime exception",
inputPath: plain,
values: map[string][]string{"nativePageRanges": {"1-2"}},
err: libreofficeapi.ErrRuntimeException,
wantStatus: http.StatusInternalServerError,
wantBody: fmt.Sprintf(unattributableFailureMessage, "page_1.docx"),
},
{
name: "nothing implicated, uno exception",
inputPath: plain,
err: libreofficeapi.ErrUnoException,
wantStatus: http.StatusInternalServerError,
wantBody: fmt.Sprintf(unattributableFailureMessage, "page_1.docx"),
},
{
name: "nothing implicated, runtime exception",
inputPath: plain,
err: libreofficeapi.ErrRuntimeException,
wantStatus: http.StatusInternalServerError,
wantBody: fmt.Sprintf(unattributableFailureMessage, "page_1.docx"),
},
{
name: "detection cannot read the document",
inputPath: unreachable,
err: libreofficeapi.ErrUnoException,
wantStatus: http.StatusInternalServerError,
wantBody: fmt.Sprintf(unattributableFailureMessage, "vanished.docx"),
},
{
name: "unreadable source",
inputPath: corrupted,
err: libreofficeapi.ErrIoException,
wantStatus: http.StatusBadRequest,
wantBody: "LibreOffice could not read the document 'corrupted.docx'. Ensure the file is not corrupted and that its extension matches its actual format.",
},
{
name: "rejected source",
inputPath: corrupted,
err: libreofficeapi.ErrIllegalArgumentException,
wantStatus: http.StatusBadRequest,
wantBody: "LibreOffice could not read the document 'corrupted.docx'. Ensure the file is not corrupted and that its extension matches its actual format.",
},
{
name: "unconvertible document",
inputPath: corrupted,
err: libreofficeapi.ErrCannotConvertException,
wantStatus: http.StatusBadRequest,
wantBody: "LibreOffice read the document 'corrupted.docx' but could not convert it to PDF. The document may be corrupted or rely on an unsupported feature.",
},
{
name: "core dumped past the retry cap",
inputPath: plain,
err: libreofficeapi.ErrCoreDumped,
wantStatus: http.StatusInternalServerError,
wantBody: http.StatusText(http.StatusInternalServerError),
},
{
name: "unmapped exit code",
inputPath: plain,
err: fmt.Errorf("convert to PDF: exit status 7"),
wantStatus: http.StatusInternalServerError,
wantBody: http.StatusText(http.StatusInternalServerError),
},
} {
t.Run(tc.name, func(t *testing.T) {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetDirPath(dir)
ctx.SetFiles(map[string]string{filepath.Base(tc.inputPath): tc.inputPath})
ctx.SetValues(tc.values)
ctx.SetLogger(slog.New(slog.DiscardHandler))
uno := &libreofficeapi.ApiMock{
ExtensionsMock: func() []string {
return []string{".docx", ".doc"}
},
PdfMock: func(_ context.Context, _ *slog.Logger, _, _ string, _ libreofficeapi.Options) error {
// Mirror the wrapping done by [libreofficeapi.Api.Pdf].
return fmt.Errorf("supervisor run task: %w", tc.err)
},
}
c := echo.New().NewContext(
httptest.NewRequest(http.MethodPost, "/forms/libreoffice/convert", nil),
httptest.NewRecorder(),
)
c.Set("context", ctx.Context)
err := convertRoute(uno, new(gotenberg.PdfEngineMock)).Handler(c)
if err == nil {
t.Fatal("expected an error, got none")
}
status, message := api.ParseError(err)
if status != tc.wantStatus {
t.Errorf("status = %d, want %d (message: %s)", status, tc.wantStatus, message)
}
if message != tc.wantBody {
t.Errorf("message =\n%s\nwant\n%s", message, tc.wantBody)
}
})
}
}

View File

@@ -13,8 +13,10 @@ import (
"sort"
"strconv"
"strings"
"sync"
"syscall"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
"go.opentelemetry.io/otel/trace"
@@ -30,6 +32,9 @@ func init() {
// [gotenberg.PdfEngine] interface.
type PdfCpu struct {
binPath string
version string
versionOnce sync.Once
}
type pdfcpuBookmark struct {
@@ -74,35 +79,60 @@ func (engine *PdfCpu) Validate() error {
// Debug returns additional debug data.
func (engine *PdfCpu) Debug() map[string]any {
debug := make(map[string]any)
return map[string]any{"version": engine.detectVersion()}
}
cmd := exec.Command(engine.binPath, "version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
output, err := cmd.Output()
if err != nil {
debug["version"] = err.Error()
return debug
}
debug["version"] = "Unable to determine pdfcpu version"
lines := strings.SplitSeq(string(output), "\n")
for line := range lines {
if after, ok := strings.CutPrefix(line, "pdfcpu:"); ok {
debug["version"] = strings.TrimSpace(after)
break
// detectVersion resolves the pdfcpu version once, preferring the value captured
// at image build time so it never spawns pdfcpu at runtime. It falls back to
// running pdfcpu version for local or non-Docker builds.
func (engine *PdfCpu) detectVersion() string {
engine.versionOnce.Do(func() {
if v, ok := gotenberg.BuildVersion("pdfcpu"); ok {
engine.version = v
return
}
cmd := exec.Command(engine.binPath, "version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
output, err := cmd.Output()
if err != nil {
engine.version = err.Error()
return
}
engine.version = "Unable to determine pdfcpu version"
lines := strings.SplitSeq(string(output), "\n")
for line := range lines {
if after, ok := strings.CutPrefix(line, "pdfcpu:"); ok {
engine.version = strings.TrimSpace(after)
break
}
}
})
return engine.version
}
// spanAttrs returns the client-span attributes for a pdfcpu invocation: the
// server address and the pdfcpu version, plus any extra attributes. The version
// rides on every span so a trace records which pdfcpu ran the operation.
func (engine *PdfCpu) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue {
attrs := make([]attribute.KeyValue, 0, 2+len(extra))
attrs = append(attrs, semconv.ServerAddress(engine.binPath))
if v := engine.detectVersion(); v != "" {
attrs = append(attrs, attribute.String("gotenberg.pdfcpu.version", v))
}
return debug
return append(attrs, extra...)
}
// Merge combines multiple PDFs into a single PDF.
func (engine *PdfCpu) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Merge",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -134,7 +164,7 @@ func (engine *PdfCpu) Merge(ctx context.Context, logger *slog.Logger, inputPaths
func (engine *PdfCpu) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Split",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -142,14 +172,14 @@ func (engine *PdfCpu) Split(ctx context.Context, logger *slog.Logger, mode goten
switch mode.Mode {
case gotenberg.SplitModeIntervals:
args = append(args, "split", "-mode", "span", inputPath, outputDirPath, mode.Span)
args = append(args, "split", "--mode", "span", inputPath, outputDirPath, mode.Span)
case gotenberg.SplitModePages:
if mode.Unify {
outputPath := fmt.Sprintf("%s/%s", outputDirPath, filepath.Base(inputPath))
args = append(args, "trim", "-pages", mode.Span, inputPath, outputPath)
args = append(args, "trim", "--pages", mode.Span, inputPath, outputPath)
break
}
args = append(args, "extract", "-mode", "page", "-pages", mode.Span, inputPath, outputDirPath)
args = append(args, "extract", "--mode", "page", "--pages", mode.Span, inputPath, outputDirPath)
default:
err := fmt.Errorf("split PDFs using mode '%s' with pdfcpu: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
span.RecordError(err)
@@ -203,7 +233,7 @@ func (engine *PdfCpu) Split(ctx context.Context, logger *slog.Logger, mode goten
func (engine *PdfCpu) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Flatten",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -217,7 +247,7 @@ func (engine *PdfCpu) Flatten(ctx context.Context, logger *slog.Logger, inputPat
func (engine *PdfCpu) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Convert",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -231,7 +261,7 @@ func (engine *PdfCpu) Convert(ctx context.Context, logger *slog.Logger, formats
func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.ReadMetadata",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -245,7 +275,7 @@ func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *slog.Logger, inp
func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.WriteMetadata",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -259,7 +289,7 @@ func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *slog.Logger, me
func (engine *PdfCpu) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.PageCount",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -273,12 +303,15 @@ func (engine *PdfCpu) PageCount(ctx context.Context, logger *slog.Logger, inputP
func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.ReadBookmarks",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
tmpPath := fmt.Sprintf("%s.read.json", inputPath)
args := []string{"bookmarks", "export", inputPath, tmpPath}
// --force: without it, a leftover file from an interrupted run makes pdfcpu
// refuse, and the stale contents would then be read as this document's
// bookmarks.
args := []string{"bookmarks", "export", "--force", inputPath, tmpPath}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
err = fmt.Errorf("create command: %w", err)
@@ -376,7 +409,7 @@ func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *slog.Logger, in
func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.WriteBookmarks",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -426,7 +459,9 @@ func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *slog.Logger, i
}
}()
args := []string{"bookmarks", "import", "-replace", inputPath, tmpPath, inputPath}
// --force: the output path is the input path, and pdfcpu refuses to
// overwrite an existing file without it.
args := []string{"bookmarks", "import", "--replace", "--force", inputPath, tmpPath, inputPath}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
err = fmt.Errorf("create command: %w", err)
@@ -447,12 +482,27 @@ func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *slog.Logger, i
return nil
}
// EmbedFilesMetadata is not available in this implementation.
func (engine *PdfCpu) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
return fmt.Errorf("set embeds metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// InjectFacturXXMP is not available in this implementation.
func (engine *PdfCpu) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error {
return fmt.Errorf("inject Factur-X XMP with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// ReadPdfAConformance is not available in this implementation.
func (engine *PdfCpu) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
return "", "", fmt.Errorf("read PDF/A conformance with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
// without modifying the main PDF content.
func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.EmbedFiles",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -488,30 +538,41 @@ func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *slog.Logger, fileP
}
// Encrypt adds password protection to a PDF file using pdfcpu.
func (engine *PdfCpu) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
func (engine *PdfCpu) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Encrypt",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
if userPassword == "" {
err := errors.New("user password cannot be empty")
ownerPassword := opts.OwnerPassword
if ownerPassword == "" {
ownerPassword = opts.UserPassword
}
// An empty user password is allowed: it produces an owner-only document.
if opts.UserPassword == "" && ownerPassword == "" {
err := errors.New("at least a user or owner password is required")
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
if ownerPassword == "" {
ownerPassword = userPassword
// pdfcpu only supports coarse permissions: all actions or none.
perm := "all"
if opts.Permissions.Restricted() {
perm = "none"
}
args := make([]string, 0, 11)
args := make([]string, 0, 12)
args = append(args, "encrypt")
args = append(args, "-mode", "aes")
args = append(args, "-upw", userPassword)
args = append(args, "-opw", ownerPassword)
args = append(args, "-perm", "all")
// --force: the output path is the input path, and pdfcpu refuses to
// overwrite an existing file without it.
args = append(args, "--force")
args = append(args, "--mode", "aes")
args = append(args, "--upw", opts.UserPassword)
args = append(args, "--opw", ownerPassword)
args = append(args, "--perm", perm)
args = append(args, inputPath, inputPath)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
@@ -538,7 +599,7 @@ func (engine *PdfCpu) Encrypt(ctx context.Context, logger *slog.Logger, inputPat
func (engine *PdfCpu) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Watermark",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -557,7 +618,7 @@ func (engine *PdfCpu) Watermark(ctx context.Context, logger *slog.Logger, inputP
func (engine *PdfCpu) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Stamp",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -576,13 +637,15 @@ func (engine *PdfCpu) Stamp(ctx context.Context, logger *slog.Logger, inputPath
func (engine *PdfCpu) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Rotate",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
args := []string{"rotate"}
// --force: the output path is the input path, and pdfcpu refuses to
// overwrite an existing file without it.
args := []string{"rotate", "--force"}
if pages != "" {
args = append(args, "-pages", pages)
args = append(args, "--pages", pages)
}
args = append(args, "--", inputPath, strconv.Itoa(angle), inputPath)
@@ -626,10 +689,12 @@ func (engine *PdfCpu) applyStampOrWatermark(ctx context.Context, logger *slog.Lo
}
description := strings.Join(descParts, ", ")
args := []string{command, "add", "-mode", mode}
// --force: the output path is the input path, and pdfcpu refuses to
// overwrite an existing file without it.
args := []string{command, "add", "--mode", mode, "--force"}
if stamp.Pages != "" {
args = append(args, "-pages", stamp.Pages)
args = append(args, "--pages", stamp.Pages)
}
args = append(args, "--", stamp.Expression, description, inputPath, inputPath)

View File

@@ -1,14 +1,17 @@
# Adding PDF Engine Features
Each new PDF engine capability (e.g., bookmarks, watermark, stamp, embed) requires a matching Makefile entry. The Makefile variables control which engines are passed to Gotenberg at `make run` and `make test-integration` time (via `compose.yaml`). If you skip this step, the flag still works when set manually, but `make run` falls back to the default defined in `pdfengines.go`, which may not include the new engine.
Each new PDF engine capability (bookmarks, watermark, stamp, embed, etc.) requires a matching Makefile entry. The Makefile variables control which engines are passed to Gotenberg at `make run` and `make test-integration` time via `compose.yaml`. Skip this step and `make run` falls back to the default defined in `pdfengines.go`, which may not include the new engine.
Every `--pdfengines-*-engines` flag registered in `pkg/modules/pdfengines/pdfengines.go` must have a corresponding variable and flag in the Makefile:
Every `--pdfengines-*-engines` flag registered in `pdfengines.go` needs two additions:
1. A variable in the Makefile's variable block (around line 60-70):
1. **Add a variable** in the Makefile's variable block (around line 60 to 70):
```makefile
PDFENGINES_<FEATURE>_ENGINES=<default engines>
```
2. **Add the flag** in `compose.yaml`'s command args:
2. A flag in `compose.yaml`'s command args:
```yaml
- "--pdfengines-<feature>-engines=${PDFENGINES_<FEATURE>_ENGINES}"
```
@@ -17,7 +20,7 @@ The default value must match the `fs.StringSlice(...)` call for that flag in `pd
## Example: Rotate
The rotate feature was added with two engines (`pdfcpu` and `pdftk`). Here is what the additions look like:
Rotate was added with two engines (`pdfcpu` and `pdftk`):
**Makefile** (variable block):

View File

@@ -0,0 +1,169 @@
package pdfengines
import (
"context"
"errors"
"fmt"
"testing"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/codes"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func newFallbackRecorder(t *testing.T) *tracetest.SpanRecorder {
t.Helper()
recorder := tracetest.NewSpanRecorder()
provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
previous := otel.GetTracerProvider()
otel.SetTracerProvider(provider)
t.Cleanup(func() { otel.SetTracerProvider(previous) })
return recorder
}
func findFallbackSpan(recorder *tracetest.SpanRecorder, name string) sdktrace.ReadOnlySpan {
for _, s := range recorder.Ended() {
if s.Name() == name {
return s
}
}
return nil
}
func wrapTest(err error) error { return fmt.Errorf("test op with multi PDF engines: %w", err) }
func TestRunWithFallback_FirstSucceeds(t *testing.T) {
recorder := newFallbackRecorder(t)
engines := []gotenberg.PdfEngine{&gotenberg.PdfEngineMock{}, &gotenberg.PdfEngineMock{}}
calls := 0
got, err := runWithFallback(context.Background(), "pdfengines.Test", engines,
func(_ context.Context, _ gotenberg.PdfEngine) (string, error) {
calls++
return "ok", nil
}, wrapTest)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "ok" {
t.Errorf("got %q, want ok", got)
}
if calls != 1 {
t.Errorf("expected 1 engine call, got %d", calls)
}
span := findFallbackSpan(recorder, "pdfengines.Test")
if span.Status().Code != codes.Ok {
t.Errorf("status = %v, want Ok", span.Status().Code)
}
attrs := map[string]string{}
for _, kv := range span.Attributes() {
attrs[string(kv.Key)] = kv.Value.Emit()
}
if attrs["gotenberg.pdf_engine.attempts"] != "1" {
t.Errorf("attempts = %q, want 1", attrs["gotenberg.pdf_engine.attempts"])
}
if attrs["gotenberg.pdf_engine.selected"] == "" {
t.Error("expected a selected engine attribute")
}
}
func TestRunWithFallback_SecondSucceeds(t *testing.T) {
recorder := newFallbackRecorder(t)
engines := []gotenberg.PdfEngine{&gotenberg.PdfEngineMock{}, &gotenberg.PdfEngineMock{}}
calls := 0
got, err := runWithFallback(context.Background(), "pdfengines.Test", engines,
func(_ context.Context, _ gotenberg.PdfEngine) (string, error) {
calls++
if calls == 1 {
return "", errors.New("first engine failed")
}
return "ok", nil
}, wrapTest)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "ok" || calls != 2 {
t.Errorf("got %q after %d calls, want ok after 2", got, calls)
}
span := findFallbackSpan(recorder, "pdfengines.Test")
var failedEvents int
for _, e := range span.Events() {
if e.Name == "pdf_engine.attempt_failed" {
failedEvents++
}
}
if failedEvents != 1 {
t.Errorf("expected 1 attempt_failed event, got %d", failedEvents)
}
for _, kv := range span.Attributes() {
if string(kv.Key) == "gotenberg.pdf_engine.attempts" && kv.Value.Emit() != "2" {
t.Errorf("attempts = %q, want 2", kv.Value.Emit())
}
}
}
func TestRunWithFallback_AllFail(t *testing.T) {
recorder := newFallbackRecorder(t)
engines := []gotenberg.PdfEngine{&gotenberg.PdfEngineMock{}, &gotenberg.PdfEngineMock{}}
sentinel := errors.New("engine failed")
_, err := runWithFallback(context.Background(), "pdfengines.Test", engines,
func(_ context.Context, _ gotenberg.PdfEngine) (string, error) {
return "", sentinel
}, wrapTest)
if err == nil {
t.Fatal("expected an error when all engines fail")
}
if !errors.Is(err, sentinel) {
t.Errorf("expected the joined engine error to be wrapped, got %v", err)
}
span := findFallbackSpan(recorder, "pdfengines.Test")
if span.Status().Code != codes.Error {
t.Errorf("status = %v, want Error", span.Status().Code)
}
}
func TestRunWithFallback_ZeroEngines(t *testing.T) {
newFallbackRecorder(t)
_, err := runWithFallback(context.Background(), "pdfengines.Test", nil,
func(_ context.Context, _ gotenberg.PdfEngine) (string, error) {
return "ok", nil
}, wrapTest)
if err == nil {
t.Error("expected an error with no engines")
}
}
func TestRunWithFallback_ContextDone(t *testing.T) {
recorder := newFallbackRecorder(t)
engines := []gotenberg.PdfEngine{&gotenberg.PdfEngineMock{}}
ctx, cancel := context.WithCancel(context.Background())
cancel()
release := make(chan struct{})
t.Cleanup(func() { close(release) })
_, err := runWithFallback(ctx, "pdfengines.Test", engines,
func(_ context.Context, _ gotenberg.PdfEngine) (string, error) {
<-release // never returns during the call, forcing the ctx.Done branch
return "", nil
}, wrapTest)
if !errors.Is(err, context.Canceled) {
t.Errorf("expected context.Canceled, got %v", err)
}
span := findFallbackSpan(recorder, "pdfengines.Test")
if span.Status().Code != codes.Error {
t.Errorf("status = %v, want Error (the cancellation must mark the span)", span.Status().Code)
}
}

View File

@@ -5,8 +5,8 @@ import (
"errors"
"fmt"
"log/slog"
"sync"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
@@ -22,11 +22,13 @@ type multiPdfEngines struct {
writeMetadataEngines []gotenberg.PdfEngine
passwordEngines []gotenberg.PdfEngine
embedEngines []gotenberg.PdfEngine
embedMetadataEngines []gotenberg.PdfEngine
readBookmarksEngines []gotenberg.PdfEngine
writeBookmarksEngines []gotenberg.PdfEngine
watermarkEngines []gotenberg.PdfEngine
stampEngines []gotenberg.PdfEngine
rotateEngines []gotenberg.PdfEngine
facturXEngines []gotenberg.PdfEngine
}
func newMultiPdfEngines(
@@ -38,11 +40,13 @@ func newMultiPdfEngines(
writeMetadataEngines,
passwordEngines,
embedEngines,
embedMetadataEngines,
readBookmarksEngines,
writeBookmarksEngines,
watermarkEngines,
stampEngines,
rotateEngines []gotenberg.PdfEngine,
rotateEngines,
facturXEngines []gotenberg.PdfEngine,
) *multiPdfEngines {
return &multiPdfEngines{
mergeEngines: mergeEngines,
@@ -53,554 +57,298 @@ func newMultiPdfEngines(
writeMetadataEngines: writeMetadataEngines,
passwordEngines: passwordEngines,
embedEngines: embedEngines,
embedMetadataEngines: embedMetadataEngines,
readBookmarksEngines: readBookmarksEngines,
writeBookmarksEngines: writeBookmarksEngines,
watermarkEngines: watermarkEngines,
stampEngines: stampEngines,
rotateEngines: rotateEngines,
facturXEngines: facturXEngines,
}
}
// engineName returns the module ID of a PDF engine for telemetry, falling back
// to its type name when it does not expose a descriptor.
func engineName(engine gotenberg.PdfEngine) string {
if module, ok := engine.(gotenberg.Module); ok {
return module.Descriptor().ID
}
return fmt.Sprintf("%T", engine)
}
// runWithFallback runs op against each engine in order and returns the first
// success. It wraps the attempts in a pdfengines span, records the winning
// engine and attempt count, emits a pdf_engine.attempt_failed event for each
// failed engine, and joins all engine errors on total failure. A context
// cancellation marks the span as errored too. wrap applies the op-specific
// final error message.
func runWithFallback[T any](
ctx context.Context,
spanName string,
engines []gotenberg.PdfEngine,
op func(ctx context.Context, engine gotenberg.PdfEngine) (T, error),
wrap func(err error) error,
) (T, error) {
var zero T
ctx, span := gotenberg.Tracer().Start(ctx, spanName, trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
type attemptResult struct {
value T
err error
}
var joined error
for attempt, engine := range engines {
resultChan := make(chan attemptResult, 1)
go func(engine gotenberg.PdfEngine) {
value, err := op(ctx, engine)
resultChan <- attemptResult{value: value, err: err}
}(engine)
select {
case result := <-resultChan:
if result.err == nil {
span.SetAttributes(
attribute.String("gotenberg.pdf_engine.selected", engineName(engine)),
attribute.Int("gotenberg.pdf_engine.attempts", attempt+1),
)
span.SetStatus(codes.Ok, "")
return result.value, nil
}
joined = errors.Join(joined, result.err)
span.AddEvent("pdf_engine.attempt_failed", trace.WithAttributes(
attribute.String("engine", engineName(engine)),
))
case <-ctx.Done():
err := ctx.Err()
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return zero, err
}
}
err := wrap(joined)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return zero, err
}
// runWithFallbackVoid adapts [runWithFallback] for operations that return no
// value beyond an error.
func runWithFallbackVoid(
ctx context.Context,
spanName string,
engines []gotenberg.PdfEngine,
op func(ctx context.Context, engine gotenberg.PdfEngine) error,
wrap func(err error) error,
) error {
_, err := runWithFallback(ctx, spanName, engines,
func(ctx context.Context, engine gotenberg.PdfEngine) (struct{}, error) {
return struct{}{}, op(ctx, engine)
},
wrap,
)
return err
}
// Merge combines multiple PDF files into a single document using the first
// available engine that supports PDF merging.
//
//nolint:dupl
func (multi *multiPdfEngines) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.Merge", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
errChan := make(chan error, 1)
for _, engine := range multi.mergeEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.Merge(ctx, logger, inputPaths, outputPath)
}(engine)
select {
case mergeErr := <-errChan:
if mergeErr != nil {
err = errors.Join(err, mergeErr)
} else {
span.SetStatus(codes.Ok, "")
return nil
}
case <-ctx.Done():
return ctx.Err()
}
}
err = fmt.Errorf("merge PDFs with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
type splitResult struct {
outputPaths []string
err error
return runWithFallbackVoid(ctx, "pdfengines.Merge", multi.mergeEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.Merge(ctx, logger, inputPaths, outputPath)
},
func(err error) error { return fmt.Errorf("merge PDFs with multi PDF engines: %w", err) },
)
}
// Split divides the PDF into separate pages using the first available engine
// that supports PDF splitting.
func (multi *multiPdfEngines) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.Split", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
var mu sync.Mutex // to safely append errors.
for _, engine := range multi.splitEngines {
resultChan := make(chan splitResult, 1)
go func(engine gotenberg.PdfEngine) {
outputPaths, err := engine.Split(ctx, logger, mode, inputPath, outputDirPath)
resultChan <- splitResult{outputPaths: outputPaths, err: err}
}(engine)
select {
case result := <-resultChan:
if result.err != nil {
mu.Lock()
err = errors.Join(err, result.err)
mu.Unlock()
} else {
span.SetStatus(codes.Ok, "")
return result.outputPaths, nil
}
case <-ctx.Done():
return nil, ctx.Err()
}
}
err = fmt.Errorf("split PDF with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
return runWithFallback(ctx, "pdfengines.Split", multi.splitEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) ([]string, error) {
return engine.Split(ctx, logger, mode, inputPath, outputDirPath)
},
func(err error) error { return fmt.Errorf("split PDF with multi PDF engines: %w", err) },
)
}
// Flatten merges existing annotation appearances with page content using the
// first available engine that supports flattening.
func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.Flatten", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
errChan := make(chan error, 1)
for _, engine := range multi.flattenEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.Flatten(ctx, logger, inputPath)
}(engine)
select {
case mergeErr := <-errChan:
if mergeErr != nil {
err = errors.Join(err, mergeErr)
} else {
span.SetStatus(codes.Ok, "")
return nil
}
case <-ctx.Done():
return ctx.Err()
}
}
err = fmt.Errorf("flatten PDF with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
return runWithFallbackVoid(ctx, "pdfengines.Flatten", multi.flattenEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.Flatten(ctx, logger, inputPath)
},
func(err error) error { return fmt.Errorf("flatten PDF with multi PDF engines: %w", err) },
)
}
// Convert transforms the given PDF to a specific PDF format using the first
// available engine that supports PDF conversion.
func (multi *multiPdfEngines) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.Convert", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
errChan := make(chan error, 1)
for _, engine := range multi.convertEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.Convert(ctx, logger, formats, inputPath, outputPath)
}(engine)
select {
case mergeErr := <-errChan:
if mergeErr != nil {
err = errors.Join(err, mergeErr)
} else {
span.SetStatus(codes.Ok, "")
return nil
}
case <-ctx.Done():
return ctx.Err()
}
}
err = fmt.Errorf("convert PDF to '%+v' with multi PDF engines: %w", formats, err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
type readMetadataResult struct {
metadata map[string]any
err error
return runWithFallbackVoid(ctx, "pdfengines.Convert", multi.convertEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.Convert(ctx, logger, formats, inputPath, outputPath)
},
func(err error) error {
return fmt.Errorf("convert PDF to '%+v' with multi PDF engines: %w", formats, err)
},
)
}
// ReadMetadata extracts metadata from a PDF file using the first available
// engine that supports metadata reading.
//
//nolint:dupl
func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.ReadMetadata", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
var mu sync.Mutex // to safely append errors.
for _, engine := range multi.readMetadataEngines {
resultChan := make(chan readMetadataResult, 1)
go func(engine gotenberg.PdfEngine) {
metadata, err := engine.ReadMetadata(ctx, logger, inputPath)
resultChan <- readMetadataResult{metadata: metadata, err: err}
}(engine)
select {
case result := <-resultChan:
if result.err != nil {
mu.Lock()
err = errors.Join(err, result.err)
mu.Unlock()
} else {
span.SetStatus(codes.Ok, "")
return result.metadata, nil
}
case <-ctx.Done():
return nil, ctx.Err()
}
}
err = fmt.Errorf("read PDF metadata with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
return runWithFallback(ctx, "pdfengines.ReadMetadata", multi.readMetadataEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) (map[string]any, error) {
return engine.ReadMetadata(ctx, logger, inputPath)
},
func(err error) error { return fmt.Errorf("read PDF metadata with multi PDF engines: %w", err) },
)
}
// WriteMetadata embeds metadata into a PDF file using the first available
// engine that supports metadata writing.
func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.WriteMetadata", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
errChan := make(chan error, 1)
for _, engine := range multi.writeMetadataEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.WriteMetadata(ctx, logger, metadata, inputPath)
}(engine)
select {
case writeMetadataErr := <-errChan:
if writeMetadataErr != nil {
err = errors.Join(err, writeMetadataErr)
} else {
span.SetStatus(codes.Ok, "")
return nil
}
case <-ctx.Done():
return ctx.Err()
}
}
err = fmt.Errorf("write PDF metadata with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
type pageCountResult struct {
pageCount int
err error
return runWithFallbackVoid(ctx, "pdfengines.WriteMetadata", multi.writeMetadataEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.WriteMetadata(ctx, logger, metadata, inputPath)
},
func(err error) error { return fmt.Errorf("write PDF metadata with multi PDF engines: %w", err) },
)
}
// PageCount returns the number of pages in a PDF file using the first available
// engine that supports metadata reading.
func (multi *multiPdfEngines) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.PageCount", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
var mu sync.Mutex // to safely append errors.
for _, engine := range multi.readMetadataEngines {
resultChan := make(chan pageCountResult, 1)
go func(engine gotenberg.PdfEngine) {
pageCount, err := engine.PageCount(ctx, logger, inputPath)
resultChan <- pageCountResult{pageCount: pageCount, err: err}
}(engine)
select {
case result := <-resultChan:
if result.err != nil {
mu.Lock()
err = errors.Join(err, result.err)
mu.Unlock()
} else {
span.SetStatus(codes.Ok, "")
return result.pageCount, nil
}
case <-ctx.Done():
return 0, ctx.Err()
}
}
err = fmt.Errorf("page count with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return 0, err
}
type readBookmarksResult struct {
bookmarks []gotenberg.Bookmark
err error
return runWithFallback(ctx, "pdfengines.PageCount", multi.readMetadataEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) (int, error) {
return engine.PageCount(ctx, logger, inputPath)
},
func(err error) error { return fmt.Errorf("page count with multi PDF engines: %w", err) },
)
}
// ReadBookmarks reads bookmarks from a PDF file using the first available
// engine that supports bookmarks reading.
//
//nolint:dupl
func (multi *multiPdfEngines) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.ReadBookmarks", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
var mu sync.Mutex // to safely append errors.
for _, engine := range multi.readBookmarksEngines {
resultChan := make(chan readBookmarksResult, 1)
go func(engine gotenberg.PdfEngine) {
bookmarks, err := engine.ReadBookmarks(ctx, logger, inputPath)
resultChan <- readBookmarksResult{bookmarks: bookmarks, err: err}
}(engine)
select {
case result := <-resultChan:
if result.err != nil {
mu.Lock()
err = errors.Join(err, result.err)
mu.Unlock()
} else {
span.SetStatus(codes.Ok, "")
return result.bookmarks, nil
}
case <-ctx.Done():
return nil, ctx.Err()
}
}
err = fmt.Errorf("read PDF bookmarks with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
return runWithFallback(ctx, "pdfengines.ReadBookmarks", multi.readBookmarksEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) ([]gotenberg.Bookmark, error) {
return engine.ReadBookmarks(ctx, logger, inputPath)
},
func(err error) error { return fmt.Errorf("read PDF bookmarks with multi PDF engines: %w", err) },
)
}
// WriteBookmarks adds a document outline (bookmarks) to a PDF file using the
// first available engine that supports bookmarks writing.
func (multi *multiPdfEngines) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.WriteBookmarks", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
errChan := make(chan error, 1)
for _, engine := range multi.writeBookmarksEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.WriteBookmarks(ctx, logger, inputPath, bookmarks)
}(engine)
select {
case writeBookmarksErr := <-errChan:
if writeBookmarksErr != nil {
err = errors.Join(err, writeBookmarksErr)
} else {
span.SetStatus(codes.Ok, "")
return nil
}
case <-ctx.Done():
return ctx.Err()
}
}
err = fmt.Errorf("write PDF bookmarks with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
return runWithFallbackVoid(ctx, "pdfengines.WriteBookmarks", multi.writeBookmarksEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.WriteBookmarks(ctx, logger, inputPath, bookmarks)
},
func(err error) error { return fmt.Errorf("write PDF bookmarks with multi PDF engines: %w", err) },
)
}
// Encrypt adds password protection to a PDF file using the first available
// engine that supports password protection.
func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.Encrypt", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
errChan := make(chan error, 1)
for _, engine := range multi.passwordEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.Encrypt(ctx, logger, inputPath, userPassword, ownerPassword)
}(engine)
select {
case protectErr := <-errChan:
if protectErr != nil {
err = errors.Join(err, protectErr)
} else {
span.SetStatus(codes.Ok, "")
return nil
}
case <-ctx.Done():
return ctx.Err()
}
}
err = fmt.Errorf("encrypt PDF using multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
return runWithFallbackVoid(ctx, "pdfengines.Encrypt", multi.passwordEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.Encrypt(ctx, logger, inputPath, opts)
},
func(err error) error { return fmt.Errorf("encrypt PDF using multi PDF engines: %w", err) },
)
}
// EmbedFiles embeds files into a PDF using the first available
// engine that supports file embedding.
//
//nolint:dupl
// EmbedFiles embeds files into a PDF using the first available engine that
// supports file embedding.
func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.EmbedFiles", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
errChan := make(chan error, 1)
for _, engine := range multi.embedEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.EmbedFiles(ctx, logger, filePaths, inputPath)
}(engine)
select {
case embedErr := <-errChan:
if embedErr != nil {
err = errors.Join(err, embedErr)
} else {
span.SetStatus(codes.Ok, "")
return nil
}
case <-ctx.Done():
return ctx.Err()
}
}
err = fmt.Errorf("embed files into PDF using multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
return runWithFallbackVoid(ctx, "pdfengines.EmbedFiles", multi.embedEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.EmbedFiles(ctx, logger, filePaths, inputPath)
},
func(err error) error { return fmt.Errorf("embed files into PDF using multi PDF engines: %w", err) },
)
}
// Watermark applies a watermark (behind page content) to a PDF file using the
// first available engine that supports watermarking.
//
//nolint:dupl
func (multi *multiPdfEngines) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.Watermark", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
errChan := make(chan error, 1)
for _, engine := range multi.watermarkEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.Watermark(ctx, logger, inputPath, stamp)
}(engine)
select {
case watermarkErr := <-errChan:
if watermarkErr != nil {
err = errors.Join(err, watermarkErr)
} else {
span.SetStatus(codes.Ok, "")
return nil
}
case <-ctx.Done():
return ctx.Err()
}
}
err = fmt.Errorf("watermark PDF with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
return runWithFallbackVoid(ctx, "pdfengines.Watermark", multi.watermarkEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.Watermark(ctx, logger, inputPath, stamp)
},
func(err error) error { return fmt.Errorf("watermark PDF with multi PDF engines: %w", err) },
)
}
// Stamp applies a stamp (on top of page content) to a PDF file using the
// first available engine that supports stamping.
//
//nolint:dupl
// Stamp applies a stamp (on top of page content) to a PDF file using the first
// available engine that supports stamping.
func (multi *multiPdfEngines) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.Stamp", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
errChan := make(chan error, 1)
for _, engine := range multi.stampEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.Stamp(ctx, logger, inputPath, stamp)
}(engine)
select {
case stampErr := <-errChan:
if stampErr != nil {
err = errors.Join(err, stampErr)
} else {
span.SetStatus(codes.Ok, "")
return nil
}
case <-ctx.Done():
return ctx.Err()
}
}
err = fmt.Errorf("stamp PDF with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
return runWithFallbackVoid(ctx, "pdfengines.Stamp", multi.stampEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.Stamp(ctx, logger, inputPath, stamp)
},
func(err error) error { return fmt.Errorf("stamp PDF with multi PDF engines: %w", err) },
)
}
// Rotate rotates pages of a PDF file using the first available engine that
// supports rotation.
func (multi *multiPdfEngines) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.Rotate", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
return runWithFallbackVoid(ctx, "pdfengines.Rotate", multi.rotateEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.Rotate(ctx, logger, inputPath, angle, pages)
},
func(err error) error { return fmt.Errorf("rotate PDF with multi PDF engines: %w", err) },
)
}
var err error
errChan := make(chan error, 1)
// EmbedFilesMetadata sets metadata on embedded files using the first available
// engine that supports it.
func (multi *multiPdfEngines) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
return runWithFallbackVoid(ctx, "pdfengines.EmbedFilesMetadata", multi.embedMetadataEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.EmbedFilesMetadata(ctx, logger, metadata, inputPath)
},
func(err error) error { return fmt.Errorf("set embeds metadata using multi PDF engines: %w", err) },
)
}
for _, engine := range multi.rotateEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.Rotate(ctx, logger, inputPath, angle, pages)
}(engine)
// InjectFacturXXMP injects Factur-X/ZUGFeRD XMP metadata using the first
// available engine that supports it.
func (multi *multiPdfEngines) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error {
return runWithFallbackVoid(ctx, "pdfengines.InjectFacturXXMP", multi.facturXEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.InjectFacturXXMP(ctx, logger, facturX, inputPath)
},
func(err error) error { return fmt.Errorf("inject Factur-X XMP with multi PDF engines: %w", err) },
)
}
select {
case rotateErr := <-errChan:
if rotateErr != nil {
err = errors.Join(err, rotateErr)
} else {
span.SetStatus(codes.Ok, "")
return nil
}
case <-ctx.Done():
return ctx.Err()
}
// ReadPdfAConformance reads the PDF/A part and conformance using the first
// available engine that supports it.
func (multi *multiPdfEngines) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
type pdfaConf struct {
part string
conformance string
}
err = fmt.Errorf("rotate PDF with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
result, err := runWithFallback(ctx, "pdfengines.ReadPdfAConformance", multi.facturXEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) (pdfaConf, error) {
part, conformance, err := engine.ReadPdfAConformance(ctx, logger, inputPath)
return pdfaConf{part: part, conformance: conformance}, err
},
func(err error) error { return fmt.Errorf("read PDF/A conformance with multi PDF engines: %w", err) },
)
return result.part, result.conformance, err
}
// Interface guards.

View File

@@ -36,11 +36,13 @@ type PdfEngines struct {
writeMetadataNames []string
encryptNames []string
embedNames []string
embedMetadataNames []string
readBookmarksNames []string
writeBookmarksNames []string
watermarkNames []string
stampNames []string
rotateNames []string
facturXNames []string
engines []gotenberg.PdfEngine
disableRoutes bool
}
@@ -59,11 +61,13 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all")
fs.StringSlice("pdfengines-encrypt-engines", []string{"qpdf", "pdftk", "pdfcpu"}, "Set the PDF engines and their order for the password protection feature - empty means all")
fs.StringSlice("pdfengines-embed-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the file embedding feature - empty means all")
fs.StringSlice("pdfengines-embed-metadata-engines", []string{"qpdf"}, "Set the PDF engines and their order for the embed metadata feature - empty means all")
fs.StringSlice("pdfengines-read-bookmarks-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the read bookmarks feature - empty means all")
fs.StringSlice("pdfengines-write-bookmarks-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the write bookmarks feature - empty means all")
fs.StringSlice("pdfengines-watermark-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the watermark feature - empty means all")
fs.StringSlice("pdfengines-stamp-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the stamp feature - empty means all")
fs.StringSlice("pdfengines-rotate-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the rotate feature - empty means all")
fs.StringSlice("pdfengines-factur-x-engines", []string{"qpdf"}, "Set the PDF engines and their order for the Factur-X XMP feature - empty means all")
fs.Bool("pdfengines-disable-routes", false, "Disable the routes")
// Deprecated flags.
@@ -91,11 +95,13 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
encryptNames := flags.MustStringSlice("pdfengines-encrypt-engines")
embedNames := flags.MustStringSlice("pdfengines-embed-engines")
embedMetadataNames := flags.MustStringSlice("pdfengines-embed-metadata-engines")
readBookmarksNames := flags.MustStringSlice("pdfengines-read-bookmarks-engines")
writeBookmarksNames := flags.MustStringSlice("pdfengines-write-bookmarks-engines")
watermarkNames := flags.MustStringSlice("pdfengines-watermark-engines")
stampNames := flags.MustStringSlice("pdfengines-stamp-engines")
rotateNames := flags.MustStringSlice("pdfengines-rotate-engines")
facturXNames := flags.MustStringSlice("pdfengines-factur-x-engines")
mod.disableRoutes = flags.MustBool("pdfengines-disable-routes")
engines, err := ctx.Modules(new(gotenberg.PdfEngine))
@@ -162,6 +168,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
mod.embedNames = embedNames
}
mod.embedMetadataNames = defaultNames
if len(embedMetadataNames) > 0 {
mod.embedMetadataNames = embedMetadataNames
}
mod.readBookmarksNames = defaultNames
if len(readBookmarksNames) > 0 {
mod.readBookmarksNames = readBookmarksNames
@@ -187,6 +198,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
mod.rotateNames = rotateNames
}
mod.facturXNames = defaultNames
if len(facturXNames) > 0 {
mod.facturXNames = facturXNames
}
return nil
}
@@ -195,7 +211,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
// actually exist.
func (mod *PdfEngines) Validate() error {
if len(mod.engines) == 0 {
return errors.New("no PDF engine")
return errors.New("no PDF engine is available; enable at least one engine module (e.g. qpdf, pdfcpu, pdftk, libreoffice-pdfengine, exiftool)")
}
availableEngines := make([]string, len(mod.engines))
@@ -236,11 +252,13 @@ func (mod *PdfEngines) Validate() error {
findNonExistingEngines(mod.writeMetadataNames)
findNonExistingEngines(mod.encryptNames)
findNonExistingEngines(mod.embedNames)
findNonExistingEngines(mod.embedMetadataNames)
findNonExistingEngines(mod.readBookmarksNames)
findNonExistingEngines(mod.writeBookmarksNames)
findNonExistingEngines(mod.watermarkNames)
findNonExistingEngines(mod.stampNames)
findNonExistingEngines(mod.rotateNames)
findNonExistingEngines(mod.facturXNames)
if len(nonExistingEngines) == 0 {
return nil
@@ -261,11 +279,13 @@ func (mod *PdfEngines) SystemMessages() []string {
fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames, " ")),
fmt.Sprintf("encrypt engines - %s", strings.Join(mod.encryptNames, " ")),
fmt.Sprintf("embed engines - %s", strings.Join(mod.embedNames, " ")),
fmt.Sprintf("embed metadata engines - %s", strings.Join(mod.embedMetadataNames, " ")),
fmt.Sprintf("read bookmarks engines - %s", strings.Join(mod.readBookmarksNames, " ")),
fmt.Sprintf("write bookmarks engines - %s", strings.Join(mod.writeBookmarksNames, " ")),
fmt.Sprintf("watermark engines - %s", strings.Join(mod.watermarkNames, " ")),
fmt.Sprintf("stamp engines - %s", strings.Join(mod.stampNames, " ")),
fmt.Sprintf("rotate engines - %s", strings.Join(mod.rotateNames, " ")),
fmt.Sprintf("factur-x engines - %s", strings.Join(mod.facturXNames, " ")),
}
}
@@ -294,11 +314,13 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
engines(mod.writeMetadataNames),
engines(mod.encryptNames),
engines(mod.embedNames),
engines(mod.embedMetadataNames),
engines(mod.readBookmarksNames),
engines(mod.writeBookmarksNames),
engines(mod.watermarkNames),
engines(mod.stampNames),
engines(mod.rotateNames),
engines(mod.facturXNames),
), nil
}
@@ -329,6 +351,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
watermarkRoute(engine),
stampRoute(engine),
rotateRoute(engine),
facturXRoute(engine),
}, nil
}

View File

@@ -443,21 +443,268 @@ func FormDataPdfEmbeds(form *api.FormData) []string {
return embedPaths
}
// FormDataPdfEncrypt extracts encryption parameters from form data.
func FormDataPdfEncrypt(form *api.FormData) (userPassword, ownerPassword string) {
form.String("userPassword", &userPassword, "")
form.String("ownerPassword", &ownerPassword, "")
return userPassword, ownerPassword
// FormDataPdfEmbedsMetadata extracts embeds metadata from form data.
// The "embedsMetadata" field is a JSON string keyed by filename.
func FormDataPdfEmbedsMetadata(form *api.FormData) map[string]map[string]string {
var metadata map[string]map[string]string
form.EmbedsMetadata(&metadata)
return metadata
}
// EncryptPdfStub adds password protection to PDF files.
func EncryptPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, userPassword, ownerPassword string, inputPaths []string) error {
if userPassword == "" {
// EmbedFilesMetadataStub sets metadata on embedded files in PDFs.
func EmbedFilesMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metadata map[string]map[string]string, inputPaths []string) error {
if len(metadata) == 0 {
return nil
}
for _, inputPath := range inputPaths {
err := engine.Encrypt(ctx, ctx.Log(), inputPath, userPassword, ownerPassword)
err := engine.EmbedFilesMetadata(ctx, ctx.Log(), metadata, inputPath)
if err != nil {
return fmt.Errorf("set embeds metadata on PDF '%s': %w", inputPath, err)
}
}
return nil
}
// FormDataPdfFacturX extracts the Factur-X parameters and the invoice XML path
// from form data. Factur-X is requested when both facturxConformanceLevel and
// facturxXml are provided. The embedded XML always takes the canonical
// [gotenberg.FacturXDocumentFileName] name.
func FormDataPdfFacturX(form *api.FormData) (gotenberg.FacturX, string) {
var (
facturxXmlPath string
conformanceLevel string
documentType string
version string
)
form.
FacturXXml(&facturxXmlPath).
Custom("facturxConformanceLevel", func(value string) error {
conformanceLevel = value
switch value {
case "",
gotenberg.FacturXConformanceMinimum,
gotenberg.FacturXConformanceBasicWL,
gotenberg.FacturXConformanceBasic,
gotenberg.FacturXConformanceEN16931,
gotenberg.FacturXConformanceExtended,
gotenberg.FacturXConformanceXRechnung:
return nil
default:
return fmt.Errorf("unsupported conformance level '%s'", value)
}
}).
Custom("facturxDocumentType", func(value string) error {
if value == "" {
documentType = gotenberg.FacturXDocumentTypeInvoice
return nil
}
documentType = value
switch value {
case gotenberg.FacturXDocumentTypeInvoice,
gotenberg.FacturXDocumentTypeOrder,
gotenberg.FacturXDocumentTypeOrderResponse,
gotenberg.FacturXDocumentTypeOrderChange:
return nil
default:
return fmt.Errorf("unsupported document type '%s'", value)
}
}).
String("facturxVersion", &version, "1.0")
return gotenberg.FacturX{
ConformanceLevel: conformanceLevel,
DocumentType: documentType,
DocumentFileName: gotenberg.FacturXDocumentFileName,
Version: version,
}, facturxXmlPath
}
// isPdfA3 reports whether the format is a PDF/A-3 variant, the only family that
// allows the embedded files Factur-X requires.
func isPdfA3(pdfA string) bool {
return pdfA == gotenberg.PdfA3a || pdfA == gotenberg.PdfA3b || pdfA == gotenberg.PdfA3u
}
// ValidateFacturXCompat enforces the Factur-X pairing and PDF/A-3 rules. It
// returns a 400 error when the request is half-specified, or when an explicit
// PDF/A format is not a PDF/A-3 variant.
func ValidateFacturXCompat(facturX gotenberg.FacturX, facturxXmlPath string, pdfFormats gotenberg.PdfFormats) error {
if facturX.ConformanceLevel == "" && facturxXmlPath == "" {
return nil
}
if facturX.ConformanceLevel == "" {
return api.WrapError(
errors.New("facturxConformanceLevel is required when facturxXml is provided"),
api.NewSentinelHttpError(http.StatusBadRequest, "Invalid form data: 'facturxConformanceLevel' is required when 'facturxXml' is provided"),
)
}
if facturxXmlPath == "" {
return api.WrapError(
errors.New("facturxXml is required when facturxConformanceLevel is set"),
api.NewSentinelHttpError(http.StatusBadRequest, "Invalid form data: 'facturxXml' file is required when 'facturxConformanceLevel' is set"),
)
}
if pdfFormats.PdfA != "" && !isPdfA3(pdfFormats.PdfA) {
return api.WrapError(
fmt.Errorf("Factur-X requires PDF/A-3, got '%s'", pdfFormats.PdfA),
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid form data: Factur-X requires a PDF/A-3 variant (PDF/A-3a, PDF/A-3b, or PDF/A-3u), got '%s'", pdfFormats.PdfA)),
)
}
return nil
}
// FacturXPdfFormats returns the PDF/A formats to convert to so the output meets
// Factur-X's PDF/A-3 requirement. It returns pdfFormats unchanged when Factur-X
// is not requested or the caller already asked for a PDF/A-3 variant. Otherwise
// it defaults to PDF/A-3b, except for pre-existing PDFs (sourceDoc false) that
// already carry PDF/A-3, which are left untouched.
func FacturXPdfFormats(ctx *api.Context, engine gotenberg.PdfEngine, facturX gotenberg.FacturX, pdfFormats gotenberg.PdfFormats, sourceDoc bool, inputPaths []string) gotenberg.PdfFormats {
if facturX.ConformanceLevel == "" || isPdfA3(pdfFormats.PdfA) {
return pdfFormats
}
if sourceDoc {
pdfFormats.PdfA = gotenberg.PdfA3b
return pdfFormats
}
// Pre-existing PDFs: keep an already-PDF/A-3 input as-is, otherwise default
// to PDF/A-3b.
for _, inputPath := range inputPaths {
part, _, err := engine.ReadPdfAConformance(ctx, ctx.Log(), inputPath)
if err != nil {
ctx.Log().DebugContext(ctx, fmt.Sprintf("read PDF/A conformance of '%s', assuming not PDF/A-3: %s", inputPath, err))
part = ""
}
if part != "3" {
pdfFormats.PdfA = gotenberg.PdfA3b
return pdfFormats
}
}
return pdfFormats
}
// ApplyFacturXStub turns each input PDF into a Factur-X document: it embeds the
// CII invoice XML under the canonical name with AFRelationship "Alternative",
// then injects the fx XMP metadata. The inputs must already be PDF/A-3 (see
// [FacturXPdfFormats]). It is a no-op when Factur-X is not requested.
func ApplyFacturXStub(ctx *api.Context, engine gotenberg.PdfEngine, facturX gotenberg.FacturX, facturxXmlPath string, inputPaths []string) error {
if facturX.ConformanceLevel == "" {
return nil
}
err := embedFacturXXml(ctx, engine, facturxXmlPath, inputPaths)
if err != nil {
return err
}
metadata := map[string]map[string]string{
facturX.DocumentFileName: {
"mimeType": "text/xml",
"relationship": "Alternative",
},
}
err = EmbedFilesMetadataStub(ctx, engine, metadata, inputPaths)
if err != nil {
return fmt.Errorf("set Factur-X embed metadata: %w", err)
}
err = InjectFacturXXMPStub(ctx, engine, facturX, inputPaths)
if err != nil {
return err
}
return nil
}
// embedFacturXXml embeds the Factur-X invoice XML into each PDF under the
// canonical [gotenberg.FacturXDocumentFileName] name, regardless of the
// uploaded file name.
func embedFacturXXml(ctx *api.Context, engine gotenberg.PdfEngine, facturxXmlPath string, inputPaths []string) error {
embedDir, err := ctx.CreateSubDirectory(uuid.New().String())
if err != nil {
return fmt.Errorf("create Factur-X embed subdirectory: %w", err)
}
canonicalPath := fmt.Sprintf("%s/%s", embedDir, gotenberg.FacturXDocumentFileName)
err = os.Symlink(facturxXmlPath, canonicalPath)
if err != nil {
return fmt.Errorf("symlink Factur-X invoice XML: %w", err)
}
for _, inputPath := range inputPaths {
err = engine.EmbedFiles(ctx, ctx.Log(), []string{canonicalPath}, inputPath)
if err != nil {
return fmt.Errorf("embed Factur-X invoice XML into PDF '%s': %w", inputPath, err)
}
}
return nil
}
// InjectFacturXXMPStub injects Factur-X XMP metadata into PDF files. If the
// Factur-X data is not set, it does nothing.
func InjectFacturXXMPStub(ctx *api.Context, engine gotenberg.PdfEngine, facturX gotenberg.FacturX, inputPaths []string) error {
if facturX.ConformanceLevel == "" {
return nil
}
for _, inputPath := range inputPaths {
err := engine.InjectFacturXXMP(ctx, ctx.Log(), facturX, inputPath)
if err != nil {
return fmt.Errorf("inject Factur-X XMP into PDF '%s': %w", inputPath, err)
}
}
return nil
}
// FormDataPdfEncrypt extracts the encryption parameters and permissions from
// form data. Permissions default to allowed.
func FormDataPdfEncrypt(form *api.FormData) gotenberg.EncryptOptions {
var opts gotenberg.EncryptOptions
form.
String("userPassword", &opts.UserPassword, "").
String("ownerPassword", &opts.OwnerPassword, "").
Bool("allowPrinting", &opts.Permissions.AllowPrinting, true).
Bool("allowCopying", &opts.Permissions.AllowCopying, true).
Bool("allowModifying", &opts.Permissions.AllowModifying, true).
Bool("allowAnnotating", &opts.Permissions.AllowAnnotating, true).
Bool("allowFillingForms", &opts.Permissions.AllowFillingForms, true).
Bool("allowAssembling", &opts.Permissions.AllowAssembling, true)
return opts
}
// ValidatePdfEncryptCompat returns a 400 error when permission restrictions are
// requested without a password to anchor them.
func ValidatePdfEncryptCompat(opts gotenberg.EncryptOptions) error {
if opts.Permissions.Restricted() && opts.UserPassword == "" && opts.OwnerPassword == "" {
return api.WrapError(
errors.New("permission restrictions require a password"),
api.NewSentinelHttpError(http.StatusBadRequest, "Invalid form data: permission restrictions require a 'userPassword' or 'ownerPassword'"),
)
}
return nil
}
// EncryptPdfStub adds password protection and permission restrictions to PDF
// files. It does nothing when no password is provided.
func EncryptPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, opts gotenberg.EncryptOptions, inputPaths []string) error {
if opts.UserPassword == "" && opts.OwnerPassword == "" {
return nil
}
for _, inputPath := range inputPaths {
err := engine.Encrypt(ctx, ctx.Log(), inputPath, opts)
if err != nil {
return fmt.Errorf("encrypt PDF '%s': %w", inputPath, err)
}
@@ -584,6 +831,50 @@ func FormDataPdfStampFile(form *api.FormData) string {
return path
}
// EnsureStampFile validates that, when stamp.Source is image or pdf, an
// uploaded stamp file was supplied, and replaces stamp.Expression with
// uploadedFile in that case. Returning an [api] HTTP 400 error prevents
// an anonymous caller from passing an arbitrary filesystem path via
// stampExpression and having pdfcpu read it. Source values of text or
// empty are passed through unchanged.
func EnsureStampFile(stamp *gotenberg.Stamp, uploadedFile string) error {
if stamp.Source != gotenberg.StampSourceImage && stamp.Source != gotenberg.StampSourcePDF {
return nil
}
if uploadedFile == "" {
return api.WrapError(
errors.New("no stamp file provided for image or pdf source"),
api.NewSentinelHttpError(
http.StatusBadRequest,
"Invalid form data: a stamp file is required for image or pdf source",
),
)
}
stamp.Expression = uploadedFile
return nil
}
// EnsureWatermarkFile mirrors [EnsureStampFile] for a watermark. The
// shape is identical: image or pdf sources must be accompanied by an
// uploaded file, and the file path replaces watermark.Expression to
// prevent pdfcpu from reading an attacker-controlled path.
func EnsureWatermarkFile(watermark *gotenberg.Stamp, uploadedFile string) error {
if watermark.Source != gotenberg.StampSourceImage && watermark.Source != gotenberg.StampSourcePDF {
return nil
}
if uploadedFile == "" {
return api.WrapError(
errors.New("no watermark file provided for image or pdf source"),
api.NewSentinelHttpError(
http.StatusBadRequest,
"Invalid form data: a watermark file is required for image or pdf source",
),
)
}
watermark.Expression = uploadedFile
return nil
}
// WatermarkStub applies a watermark to a list of PDF files. If the stamp has
// no source, it does nothing.
func WatermarkStub(ctx *api.Context, engine gotenberg.PdfEngine, stamp gotenberg.Stamp, inputPaths []string) error {
@@ -631,13 +922,15 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
pdfFormats := FormDataPdfFormats(form)
metadata := FormDataPdfMetadata(form, false)
bookmarks := FormDataPdfBookmarks(form, false)
userPassword, ownerPassword := FormDataPdfEncrypt(form)
encrypt := FormDataPdfEncrypt(form)
embedPaths := FormDataPdfEmbeds(form)
watermark := FormDataPdfWatermark(form, false)
watermarkFile := FormDataPdfWatermarkFile(form)
stamp := FormDataPdfStamp(form, false)
stampFile := FormDataPdfStampFile(form)
angle, rotatePages := FormDataPdfRotate(form, false)
embedsMetadata := FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := FormDataPdfFacturX(form)
var inputPaths []string
var flatten bool
@@ -651,14 +944,26 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
watermark.Expression = watermarkFile
err = EnsureWatermarkFile(&watermark, watermarkFile)
if err != nil {
return fmt.Errorf("validate watermark: %w", err)
}
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
stamp.Expression = stampFile
err = EnsureStampFile(&stamp, stampFile)
if err != nil {
return fmt.Errorf("validate stamp: %w", err)
}
err = ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
err = ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
if err != nil {
return err
}
err = ValidatePdfEncryptCompat(encrypt)
if err != nil {
return err
}
err = ValidateFacturXCompat(facturX, facturxXmlPath, pdfFormats)
if err != nil {
return err
}
@@ -693,6 +998,8 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
}
}
pdfFormats = FacturXPdfFormats(ctx, engine, facturX, pdfFormats, false, outputPaths)
outputPaths, err = ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil {
return fmt.Errorf("convert PDF: %w", err)
@@ -754,7 +1061,17 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("embed files into PDFs: %w", err)
}
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
err = EmbedFilesMetadataStub(ctx, engine, embedsMetadata, outputPaths)
if err != nil {
return fmt.Errorf("set embeds metadata: %w", err)
}
err = ApplyFacturXStub(ctx, engine, facturX, facturxXmlPath, outputPaths)
if err != nil {
return fmt.Errorf("apply Factur-X: %w", err)
}
err = EncryptPdfStub(ctx, engine, encrypt, outputPaths)
if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err)
}
@@ -782,13 +1099,15 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
mode := FormDataPdfSplitMode(form, true)
pdfFormats := FormDataPdfFormats(form)
metadata := FormDataPdfMetadata(form, false)
userPassword, ownerPassword := FormDataPdfEncrypt(form)
encrypt := FormDataPdfEncrypt(form)
embedPaths := FormDataPdfEmbeds(form)
watermark := FormDataPdfWatermark(form, false)
watermarkFile := FormDataPdfWatermarkFile(form)
stamp := FormDataPdfStamp(form, false)
stampFile := FormDataPdfStampFile(form)
angle, rotatePages := FormDataPdfRotate(form, false)
embedsMetadata := FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := FormDataPdfFacturX(form)
var inputPaths []string
var flatten bool
@@ -800,14 +1119,26 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
watermark.Expression = watermarkFile
err = EnsureWatermarkFile(&watermark, watermarkFile)
if err != nil {
return fmt.Errorf("validate watermark: %w", err)
}
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
stamp.Expression = stampFile
err = EnsureStampFile(&stamp, stampFile)
if err != nil {
return fmt.Errorf("validate stamp: %w", err)
}
err = ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
err = ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
if err != nil {
return err
}
err = ValidatePdfEncryptCompat(encrypt)
if err != nil {
return err
}
err = ValidateFacturXCompat(facturX, facturxXmlPath, pdfFormats)
if err != nil {
return err
}
@@ -839,6 +1170,8 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
}
}
pdfFormats = FacturXPdfFormats(ctx, engine, facturX, pdfFormats, false, outputPaths)
convertOutputPaths, err := ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil {
return fmt.Errorf("convert PDFs: %w", err)
@@ -856,7 +1189,17 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("embed files into PDFs: %w", err)
}
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
err = EmbedFilesMetadataStub(ctx, engine, embedsMetadata, convertOutputPaths)
if err != nil {
return fmt.Errorf("set embeds metadata: %w", err)
}
err = ApplyFacturXStub(ctx, engine, facturX, facturxXmlPath, convertOutputPaths)
if err != nil {
return fmt.Errorf("apply Factur-X: %w", err)
}
err = EncryptPdfStub(ctx, engine, encrypt, convertOutputPaths)
if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err)
}
@@ -1140,20 +1483,26 @@ func encryptRoute(engine gotenberg.PdfEngine) api.Route {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
encrypt := FormDataPdfEncrypt(form)
var inputPaths []string
var userPassword string
var ownerPassword string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
MandatoryString("userPassword", &userPassword).
String("ownerPassword", &ownerPassword, "").
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, inputPaths)
// At least one password is required; an empty user password with an
// owner password yields an owner-only document.
if encrypt.UserPassword == "" && encrypt.OwnerPassword == "" {
return api.WrapError(
errors.New("no password provided"),
api.NewSentinelHttpError(http.StatusBadRequest, "Invalid form data: a 'userPassword' or 'ownerPassword' is required"),
)
}
err = EncryptPdfStub(ctx, engine, encrypt, inputPaths)
if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err)
}
@@ -1180,6 +1529,8 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route {
form := ctx.FormData()
embedPaths := FormDataPdfEmbeds(form)
embedsMetadata := FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := FormDataPdfFacturX(form)
var inputPaths []string
err := form.
@@ -1188,12 +1539,36 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route {
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
err = EmbedFilesStub(ctx, engine, embedPaths, inputPaths)
err = ValidateFacturXCompat(facturX, facturxXmlPath, gotenberg.PdfFormats{})
if err != nil {
return err
}
// Factur-X requires PDF/A-3. Convert when needed; a no-op otherwise,
// so a plain embed request keeps its inputs untouched.
pdfFormats := FacturXPdfFormats(ctx, engine, facturX, gotenberg.PdfFormats{}, false, inputPaths)
outputPaths, err := ConvertStub(ctx, engine, pdfFormats, inputPaths)
if err != nil {
return fmt.Errorf("convert PDFs: %w", err)
}
err = EmbedFilesStub(ctx, engine, embedPaths, outputPaths)
if err != nil {
return fmt.Errorf("embed files into PDFs: %w", err)
}
err = ctx.AddOutputPaths(inputPaths...)
err = EmbedFilesMetadataStub(ctx, engine, embedsMetadata, outputPaths)
if err != nil {
return fmt.Errorf("set embeds metadata: %w", err)
}
err = ApplyFacturXStub(ctx, engine, facturX, facturxXmlPath, outputPaths)
if err != nil {
return fmt.Errorf("apply Factur-X: %w", err)
}
err = ctx.AddOutputPaths(outputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
}
@@ -1226,17 +1601,9 @@ func watermarkRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
if stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF {
if watermarkFile == "" {
return api.WrapError(
errors.New("no watermark file provided"),
api.NewSentinelHttpError(
http.StatusBadRequest,
"Invalid form data: a watermark file is required for image or pdf source",
),
)
}
stamp.Expression = watermarkFile
err = EnsureWatermarkFile(&stamp, watermarkFile)
if err != nil {
return fmt.Errorf("validate watermark: %w", err)
}
err = WatermarkStub(ctx, engine, stamp, inputPaths)
@@ -1277,17 +1644,9 @@ func stampRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
if stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF {
if stampFile == "" {
return api.WrapError(
errors.New("no stamp file provided"),
api.NewSentinelHttpError(
http.StatusBadRequest,
"Invalid form data: a stamp file is required for image or pdf source",
),
)
}
stamp.Expression = stampFile
err = EnsureStampFile(&stamp, stampFile)
if err != nil {
return fmt.Errorf("validate stamp: %w", err)
}
err = StampStub(ctx, engine, stamp, inputPaths)
@@ -1339,3 +1698,61 @@ func rotateRoute(engine gotenberg.PdfEngine) api.Route {
},
}
}
// facturXRoute returns an [api.Route] which turns existing PDFs into Factur-X
// documents: it ensures PDF/A-3, embeds the CII invoice XML, and injects the fx
// XMP metadata.
func facturXRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/factur-x",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
pdfFormats := FormDataPdfFormats(form)
facturX, facturxXmlPath := FormDataPdfFacturX(form)
var inputPaths []string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
// Factur-X is the whole point of this route, so both fields are
// mandatory here.
if facturX.ConformanceLevel == "" || facturxXmlPath == "" {
return api.WrapError(
errors.New("facturxConformanceLevel and facturxXml are required"),
api.NewSentinelHttpError(http.StatusBadRequest, "Invalid form data: 'facturxConformanceLevel' and 'facturxXml' are both required"),
)
}
err = ValidateFacturXCompat(facturX, facturxXmlPath, pdfFormats)
if err != nil {
return err
}
pdfFormats = FacturXPdfFormats(ctx, engine, facturX, pdfFormats, false, inputPaths)
outputPaths, err := ConvertStub(ctx, engine, pdfFormats, inputPaths)
if err != nil {
return fmt.Errorf("convert PDFs: %w", err)
}
err = ApplyFacturXStub(ctx, engine, facturX, facturxXmlPath, outputPaths)
if err != nil {
return fmt.Errorf("apply Factur-X: %w", err)
}
err = ctx.AddOutputPaths(outputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
}
return nil
},
}
}

View File

@@ -9,8 +9,10 @@ import (
"os"
"os/exec"
"path/filepath"
"sync"
"syscall"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
"go.opentelemetry.io/otel/trace"
@@ -26,6 +28,9 @@ func init() {
// interface.
type PdfTk struct {
binPath string
version string
versionOnce sync.Once
}
// Descriptor returns a [PdfTk]'s module descriptor.
@@ -60,32 +65,58 @@ func (engine *PdfTk) Validate() error {
// Debug returns additional debug data.
func (engine *PdfTk) Debug() map[string]any {
debug := make(map[string]any)
return map[string]any{"version": engine.detectVersion()}
}
cmd := exec.Command(engine.binPath, "--version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
// detectVersion resolves the PDFtk version once, preferring the value captured
// at image build time so it never spawns the PDFtk JVM at runtime. It falls
// back to running pdftk --version for local or non-Docker builds.
func (engine *PdfTk) detectVersion() string {
engine.versionOnce.Do(func() {
if v, ok := gotenberg.BuildVersion("pdftk"); ok {
engine.version = v
return
}
output, err := cmd.Output()
if err != nil {
debug["version"] = err.Error()
return debug
cmd := exec.Command(engine.binPath, "--version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
output, err := cmd.Output()
if err != nil {
engine.version = err.Error()
return
}
lines := bytes.SplitN(output, []byte("\n"), 2)
if len(lines) > 0 {
engine.version = string(lines[0])
return
}
engine.version = "Unable to determine PDFtk version"
})
return engine.version
}
// spanAttrs returns the client-span attributes for a PDFtk invocation: the
// server address and the PDFtk version, plus any extra attributes. The version
// rides on every span so a trace records which PDFtk ran the operation.
func (engine *PdfTk) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue {
attrs := make([]attribute.KeyValue, 0, 2+len(extra))
attrs = append(attrs, semconv.ServerAddress(engine.binPath))
if v := engine.detectVersion(); v != "" {
attrs = append(attrs, attribute.String("gotenberg.pdftk.version", v))
}
lines := bytes.SplitN(output, []byte("\n"), 2)
if len(lines) > 0 {
debug["version"] = string(lines[0])
} else {
debug["version"] = "Unable to determine PDFtk version"
}
return debug
return append(attrs, extra...)
}
// Split splits a given PDF file.
func (engine *PdfTk) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Split",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -132,7 +163,7 @@ func (engine *PdfTk) Split(ctx context.Context, logger *slog.Logger, mode gotenb
func (engine *PdfTk) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Merge",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -164,7 +195,7 @@ func (engine *PdfTk) Merge(ctx context.Context, logger *slog.Logger, inputPaths
func (engine *PdfTk) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "pdftk.Flatten",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -178,7 +209,7 @@ func (engine *PdfTk) Flatten(ctx context.Context, logger *slog.Logger, inputPath
func (engine *PdfTk) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "pdftk.Convert",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -192,7 +223,7 @@ func (engine *PdfTk) Convert(ctx context.Context, logger *slog.Logger, formats g
func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
_, span := gotenberg.Tracer().Start(ctx, "pdftk.ReadMetadata",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -206,7 +237,7 @@ func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *slog.Logger, inpu
func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "pdftk.WriteMetadata",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -220,7 +251,7 @@ func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *slog.Logger, met
func (engine *PdfTk) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
_, span := gotenberg.Tracer().Start(ctx, "pdftk.PageCount",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -234,7 +265,7 @@ func (engine *PdfTk) PageCount(ctx context.Context, logger *slog.Logger, inputPa
func (engine *PdfTk) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
_, span := gotenberg.Tracer().Start(ctx, "pdftk.WriteBookmarks",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -248,7 +279,7 @@ func (engine *PdfTk) WriteBookmarks(ctx context.Context, logger *slog.Logger, in
func (engine *PdfTk) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
_, span := gotenberg.Tracer().Start(ctx, "pdftk.ReadBookmarks",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -259,21 +290,21 @@ func (engine *PdfTk) ReadBookmarks(ctx context.Context, logger *slog.Logger, inp
}
// Encrypt adds password protection to a PDF file using PDFtk.
func (engine *PdfTk) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
func (engine *PdfTk) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Encrypt",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
if userPassword == "" {
err := errors.New("user password cannot be empty")
if opts.UserPassword == "" || opts.Permissions.Restricted() {
err := gotenberg.NewPdfEngineInvalidArgs("pdftk", "owner-only encryption and permission restrictions are not supported; consider switching to another PDF engine (e.g. qpdf)")
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
if ownerPassword == userPassword || ownerPassword == "" {
if opts.OwnerPassword == opts.UserPassword || opts.OwnerPassword == "" {
err := gotenberg.NewPdfEngineInvalidArgs("pdftk", "both 'userPassword' and 'ownerPassword' must be provided and different. Consider switching to another PDF engine if this behavior does not work with your workflow")
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
@@ -287,8 +318,8 @@ func (engine *PdfTk) Encrypt(ctx context.Context, logger *slog.Logger, inputPath
args = append(args, inputPath)
args = append(args, "output", tmpPath)
args = append(args, "encrypt_128bit")
args = append(args, "user_pw", userPassword)
args = append(args, "owner_pw", ownerPassword)
args = append(args, "user_pw", opts.UserPassword)
args = append(args, "owner_pw", opts.OwnerPassword)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
@@ -322,7 +353,7 @@ func (engine *PdfTk) Encrypt(ctx context.Context, logger *slog.Logger, inputPath
func (engine *PdfTk) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "pdftk.EmbedFiles",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -339,7 +370,7 @@ func (engine *PdfTk) EmbedFiles(ctx context.Context, logger *slog.Logger, filePa
func (engine *PdfTk) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Watermark",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -389,7 +420,7 @@ func (engine *PdfTk) Watermark(ctx context.Context, logger *slog.Logger, inputPa
func (engine *PdfTk) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Stamp",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -438,7 +469,7 @@ func (engine *PdfTk) Stamp(ctx context.Context, logger *slog.Logger, inputPath s
func (engine *PdfTk) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Rotate",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -495,6 +526,21 @@ func (engine *PdfTk) Rotate(ctx context.Context, logger *slog.Logger, inputPath
return nil
}
// EmbedFilesMetadata is not available in this implementation.
func (engine *PdfTk) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
return fmt.Errorf("set embeds metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// InjectFacturXXMP is not available in this implementation.
func (engine *PdfTk) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error {
return fmt.Errorf("inject Factur-X XMP with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// ReadPdfAConformance is not available in this implementation.
func (engine *PdfTk) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
return "", "", fmt.Errorf("read PDF/A conformance with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Interface guards.
var (
_ gotenberg.Module = (*PdfTk)(nil)

View File

@@ -0,0 +1,76 @@
package qpdf
import (
"reflect"
"testing"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestQpdfPermissionArgs(t *testing.T) {
allAllowed := gotenberg.PdfPermissions{
AllowPrinting: true,
AllowCopying: true,
AllowModifying: true,
AllowAnnotating: true,
AllowFillingForms: true,
AllowAssembling: true,
}
for _, tc := range []struct {
scenario string
perms gotenberg.PdfPermissions
expect []string
}{
{
scenario: "all allowed yields no flags",
perms: allAllowed,
expect: nil,
},
{
scenario: "printing denied",
perms: gotenberg.PdfPermissions{
AllowPrinting: false,
AllowCopying: true,
AllowModifying: true,
AllowAnnotating: true,
AllowFillingForms: true,
AllowAssembling: true,
},
expect: []string{
"--print=none",
"--extract=y",
"--modify-other=y",
"--annotate=y",
"--form=y",
"--assemble=y",
},
},
{
scenario: "copying denied",
perms: gotenberg.PdfPermissions{
AllowPrinting: true,
AllowCopying: false,
AllowModifying: true,
AllowAnnotating: true,
AllowFillingForms: true,
AllowAssembling: true,
},
expect: []string{
"--print=full",
"--extract=n",
"--modify-other=y",
"--annotate=y",
"--form=y",
"--assemble=y",
},
},
} {
t.Run(tc.scenario, func(t *testing.T) {
got := qpdfPermissionArgs(tc.perms)
if !reflect.DeepEqual(got, tc.expect) {
t.Errorf("expected %v but got %v", tc.expect, got)
}
})
}
}

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