Compare commits

..

87 Commits

Author SHA1 Message Date
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
116 changed files with 7291 additions and 1498 deletions

View File

@@ -50,9 +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,9 +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,9 +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

@@ -67,9 +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

@@ -15,6 +15,10 @@ body:multipart-form {
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

@@ -12,6 +12,12 @@ 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:

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

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,10 +38,10 @@ 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
@@ -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

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

@@ -85,6 +85,15 @@ If a change violates backward compatibility, flag it as a breaking change in the
- No panics in production code paths.
- Validate input defensively.
### Error messages
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.
- 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
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.

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=
@@ -28,6 +30,9 @@ GOTENBERG_API_BASIC_AUTH_USERNAME=
GOTENBERG_API_BASIC_AUTH_PASSWORD=
API_DOWNLOAD_FROM_ALLOW_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
@@ -60,11 +68,15 @@ 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
@@ -80,6 +92,7 @@ PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
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
@@ -95,6 +108,9 @@ OTEL_EXPORTER_OTLP_INSECURE=true
WEBHOOK_ENABLE_SYNC_MODE=false
WEBHOOK_ALLOW_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
@@ -156,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.2
ARG GOLANG_VERSION=1.26.5
# ----------------------------------------------
# pdfcpu binary build stage
@@ -11,7 +11,7 @@ ARG GOLANG_VERSION=1.26.2
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
# See https://github.com/pdfcpu/pdfcpu/releases.
ARG PDFCPU_VERSION=v0.12.0
ARG PDFCPU_VERSION=v0.13.0
ENV CGO_ENABLED=0
# Define the working directory outside of $GOPATH (we're using go modules).
@@ -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.3.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,6 +125,7 @@ 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 \
@@ -189,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
@@ -268,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
@@ -328,6 +346,9 @@ 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
@@ -382,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}"
@@ -61,11 +69,15 @@ services:
- "--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}"
@@ -80,6 +92,7 @@ services:
- "--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}"
@@ -89,6 +102,9 @@ services:
- "--webhook-enable-sync-mode=${WEBHOOK_ENABLE_SYNC_MODE}"
- "--webhook-allow-list=${WEBHOOK_ALLOW_LIST}"
- "--webhook-deny-list=${WEBHOOK_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}"

125
go.mod
View File

@@ -4,36 +4,37 @@ go 1.26.2
require (
github.com/alexliesenfeld/health v0.8.1
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
github.com/chromedp/chromedp v0.14.2
github.com/cucumber/godog v0.15.1
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-20260412113850-134a5b2cce7f
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/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/moby/moby/api v1.54.1
github.com/prometheus/client_golang v1.23.2
github.com/shirou/gopsutil/v4 v4.26.3
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.42.0
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/log v0.19.0
go.opentelemetry.io/otel/metric v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/sdk/log v0.19.0
go.opentelemetry.io/otel/sdk/metric v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
golang.org/x/net v0.53.0
golang.org/x/sync v0.20.0
golang.org/x/term v0.42.0
golang.org/x/text v0.36.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 (
@@ -45,7 +46,7 @@ require (
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
@@ -56,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.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-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.21 // 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/moby/client v0.4.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/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/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.68.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.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.50.0 // indirect
golang.org/x/sys v0.43.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-20260406210006-6f92a3bedf2d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect
google.golang.org/grpc v1.80.0 // 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
)

261
go.sum
View File

@@ -18,8 +18,8 @@ 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=
@@ -44,16 +44,14 @@ 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.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
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/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=
@@ -61,24 +59,24 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
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.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
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=
@@ -90,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-20260412113850-134a5b2cce7f h1:C5vKBogs/Qf5ID8F8XuRO8SFL+5SH7JMJrAfdLAZ2iA=
github.com/gomarkdown/markdown v0.0.0-20260412113850-134a5b2cce7f/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=
@@ -105,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=
@@ -128,24 +120,20 @@ 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/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=
@@ -154,30 +142,30 @@ github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRt
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.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
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.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
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/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=
@@ -186,46 +174,44 @@ 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/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/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.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
github.com/shirou/gopsutil/v4 v4.26.3/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=
@@ -234,15 +220,14 @@ github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+Q
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.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
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=
@@ -256,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.18.0 h1:hhPGP3zvvy1xWT9RTy970wlniSxFttBIsAK1gvMguJM=
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0/go.mod h1:twJF7inoMza6kxMcF8JOdL3mPmtOZu7GEr34CUNE6Dg=
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 h1:w3zlHYETbDwXyWHZlyyR58ZC39XGi8rAhkBgUgJ9d5w=
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0/go.mod h1:GR/mClR2nn7vE8RLwxKjoBNg+QtgdDhRzxVa93koy5o=
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0 h1:0D3GFvELGIwQGfC6agLsbrEYSGWZTRTxIXxcQUqrOuk=
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0/go.mod h1:DM2NV7Zb8CcGeVPt6glouY0FAiwZQ/iqgcWExhgWeN8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 h1:Dn8rkudDzY6KV9dr/D/bTUuWgqDf9xe0rr4G2elrn0Y=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0/go.mod h1:gMk9F0xDgyN9M/3Ed5Y1wKcx/9mlU91NXY2SNq7RQuU=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0 h1:GJkybS+crDMdExT/BUNCEgfrmfboztcS6PhvSo88HKM=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0/go.mod h1:NuAyxRYIG2lKX3YQkB+83StTxM7s52PUUkRRiC0wnYI=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4=
go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko=
go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg=
go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk=
go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
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=
@@ -310,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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
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.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-20260406210006-6f92a3bedf2d h1:/aDRtSZJjyLQzm75d+a1wOJaqyKBMvIAfeQmoa3ORiI=
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:etfGUgejTiadZAUaEP14NP97xi1RGeawqkjDARA/UOs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
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=

107
package-lock.json generated
View File

@@ -5,9 +5,9 @@
"packages": {
"": {
"devDependencies": {
"prettier": "3.8.3",
"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.5.2",
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.5.2.tgz",
"integrity": "sha512-Hbr7yen4fP5TxGM54ucXa4o5NwWXatJ6Bd9I8gp0PValYbI4Rug2Gu+rVv7K7o/efQc3F5ctqWJz47rYaa8zBw==",
"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.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"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.1",
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.18.1.tgz",
"integrity": "sha512-uZmU22wBMevjh3rmCatNQqiEer2+5KLa0xYCBX6zQQUQkcNzVL+s6FbPKK6ZSUNUbQk6jMAcQHrYPvuL2W6ihQ==",
"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.5.1",
"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.3",
"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].
@@ -92,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 {
@@ -138,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,63 @@ 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.41.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.
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
}
merged, err := resource.Merge(detected, base)
if err != nil {
logger.WarnContext(ctx, fmt.Sprintf("merge OpenTelemetry resource: %s", err))
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 +101,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 +124,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,151 @@
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.41.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()
}
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,21 +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
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
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 {
@@ -94,8 +96,8 @@ 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 {
@@ -122,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)
@@ -152,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 {
@@ -189,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)

View File

@@ -1,7 +1,10 @@
package gotenberg
import (
"bufio"
"context"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"net"
@@ -12,6 +15,7 @@ import (
"time"
"github.com/dlclark/regexp2"
"golang.org/x/net/http/httpproxy"
)
// ErrNonPublicIP indicates that an outbound URL targets an IP address that
@@ -45,11 +49,45 @@ var outboundDialer = &net.Dialer{
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
@@ -65,6 +103,13 @@ func IsPublicIP(addr netip.Addr) bool {
addr.IsInterfaceLocalMulticast():
return false
}
if addr.Is6() {
for _, p := range nonPublicIPv6Prefixes {
if p.Contains(addr) {
return false
}
}
}
return true
}
@@ -348,9 +393,37 @@ func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, er
//
// The default posture is permissive; callers pass [WithDenyPrivateIPs]
// or [WithDenyPublicIPs] to opt into IP-class rejection.
func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2.Regexp, opts ...DecideOption) *http.Client {
//
// 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. In that mode the proxy
// owns DNS and egress, so destination dial pinning does not apply; the URL
// allow/deny and IP-class validation still runs. 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()
base.DialContext = secureDialContext
if enableEnvironmentProxy {
// Route through the operator's proxy (standard env vars, credentials
// included). NO_PROXY hosts get a direct, unpinned dial.
// 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)
}
base.DialContext = outboundDialer.DialContext
} 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{
@@ -411,3 +484,99 @@ func DialPinned(ctx context.Context, network string, addrs []netip.Addr, port st
}
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)
}

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

@@ -51,6 +51,38 @@ func TestIsPublicIP(t *testing.T) {
// 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)

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,11 +297,11 @@ 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.
@@ -216,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,10 +80,32 @@ 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
@@ -100,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,
@@ -195,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 {
@@ -252,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) {
@@ -390,8 +484,8 @@ func (s *processSupervisor) ensureStarted(ctx context.Context) error {
return nil
}
err := s.runWithDeadline(ctx, func() error {
return s.Launch()
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)
@@ -400,6 +494,27 @@ func (s *processSupervisor) ensureStarted(ctx context.Context) error {
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
}
// ensureHealthy checks the underlying process health and triggers a
// synchronous restart if the process is unhealthy. Skips the check if a
// restart is already in progress.
@@ -410,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)
}
@@ -433,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))
@@ -447,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)
@@ -475,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 {
@@ -514,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 {
@@ -920,7 +1023,7 @@ func TestProcessSupervisor_RetryAfterFailedFirstStart(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, 0).(*processSupervisor)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -966,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() {
@@ -988,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,12 +57,13 @@ type Api struct {
}
type downloadFromConfig struct {
allowList []*regexp2.Regexp
denyList []*regexp2.Regexp
denyPrivateIPs bool
denyPublicIPs bool
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].
@@ -201,6 +202,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
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")
@@ -239,12 +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"),
denyPrivateIPs: flags.MustBool("api-download-from-deny-private-ips"),
denyPublicIPs: flags.MustBool("api-download-from-deny-public-ips"),
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")

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 {
@@ -272,7 +281,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
}
client := &retryablehttp.Client{
HTTPClient: gotenberg.NewOutboundHttpClient(time.Until(deadline), downloadFromCfg.allowList, downloadFromCfg.denyList, ipOpts...),
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),
@@ -348,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.
@@ -389,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
})
@@ -410,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 {
@@ -428,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.
@@ -469,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)
}
@@ -516,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 {
@@ -658,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

@@ -26,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
@@ -475,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.
@@ -505,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

@@ -43,7 +43,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 +51,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 {

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,6 +38,7 @@ type browserArguments struct {
allowFileAccessFromFiles bool
hostResolverRules string
proxyServer string
enableEnvironmentProxy bool
wsUrlReadTimeout time.Duration
hyphenDataDirPath string
@@ -58,6 +59,14 @@ 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
@@ -69,7 +78,7 @@ func newChromiumBrowser(arguments browserArguments) browser {
initialCtx: context.Background(),
arguments: arguments,
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
pinningProxy: newPinningProxy(arguments.allowList, arguments.denyList, arguments.denyPrivateIPs, arguments.denyPublicIPs),
pinningProxy: newPinningProxy(arguments.allowList, arguments.denyList, arguments.denyPrivateIPs, arguments.denyPublicIPs, arguments.enableEnvironmentProxy),
}
b.isStarted.Store(false)
@@ -77,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")
}
@@ -169,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)
}
@@ -305,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(),
@@ -325,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(),
@@ -351,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")
}
@@ -387,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.

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.
@@ -294,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)
@@ -350,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,
}
}
@@ -448,6 +460,7 @@ 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")
@@ -479,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{
@@ -495,6 +508,7 @@ 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,
@@ -512,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))
@@ -628,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
}
@@ -639,12 +671,12 @@ func (mod *Chromium) Validate() error {
_, 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
@@ -692,19 +724,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.
@@ -794,19 +853,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()
@@ -819,21 +891,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() {
@@ -857,9 +920,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, "")
@@ -871,19 +937,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()
@@ -896,23 +971,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() {
@@ -936,6 +1000,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())
@@ -950,6 +1016,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

@@ -23,6 +23,26 @@ 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
@@ -428,19 +448,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")
@@ -451,19 +476,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")
@@ -474,19 +500,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")
@@ -497,19 +524,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")
@@ -520,19 +548,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

@@ -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

@@ -9,10 +9,12 @@ import (
"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"
)
@@ -43,6 +45,14 @@ type pinningProxy struct {
// 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
@@ -57,8 +67,8 @@ type pinningProxy struct {
// [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 bool) *pinningProxy {
return &pinningProxy{
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) {
@@ -73,6 +83,14 @@ func newPinningProxy(allowList, denyList []*regexp2.Regexp, denyPrivateIPs, deny
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
@@ -179,13 +197,33 @@ func (p *pinningProxy) handleConnect(w http.ResponseWriter, req *http.Request) {
// the CONNECT handling beyond filtering.
decision, err := p.decide(req.Context(), "https://"+req.Host, p.allowList, p.denyList, deadline)
if err != nil {
p.logger.WarnContext(req.Context(), fmt.Sprintf("CONNECT blocked for '%s': %s", req.Host, err))
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:
@@ -194,7 +232,11 @@ func (p *pinningProxy) handleConnect(w http.ResponseWriter, req *http.Request) {
err = errors.New("no pinned addresses and not bypassed")
}
if err != nil {
p.logger.WarnContext(req.Context(), fmt.Sprintf("CONNECT dial failed for '%s': %s", req.Host, err))
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
}
@@ -214,7 +256,11 @@ func (p *pinningProxy) handleConnect(w http.ResponseWriter, req *http.Request) {
_, err = client.Write([]byte("HTTP/1.1 200 OK\r\n\r\n"))
if err != nil {
p.logger.WarnContext(req.Context(), fmt.Sprintf("write CONNECT ack: %s", err))
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
}
@@ -254,22 +300,44 @@ func (p *pinningProxy) handleForward(w http.ResponseWriter, req *http.Request) {
decision, err := p.decide(req.Context(), req.URL.String(), p.allowList, p.denyList, deadline)
if err != nil {
p.logger.WarnContext(req.Context(), fmt.Sprintf("forward blocked for '%s': %s", req.URL, err))
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{
// 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.
DisableKeepAlives: true,
Proxy: nil,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
}
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)
@@ -282,13 +350,17 @@ func (p *pinningProxy) handleForward(w http.ResponseWriter, req *http.Request) {
default:
return nil, errors.New("no pinned addresses and not bypassed")
}
},
}
}
defer transport.CloseIdleConnections()
resp, err := transport.RoundTrip(outReq)
if err != nil {
p.logger.WarnContext(req.Context(), fmt.Sprintf("forward RoundTrip failed for '%s': %s", req.URL, err))
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
}
@@ -326,3 +398,29 @@ func copyHeaders(dst, src http.Header) {
}
}
}
// 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

@@ -13,6 +13,7 @@ import (
"net/netip"
"net/url"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -22,6 +23,33 @@ import (
"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))
}
@@ -84,7 +112,7 @@ func TestPinningProxy_Forward_Pinned_Success(t *testing.T) {
upstreamURL := mustParseURL(t, upstream.URL)
var decideCalls atomic.Int32
p := newPinningProxy(nil, nil, false, false)
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
@@ -123,7 +151,7 @@ func TestPinningProxy_Forward_Pinned_Success(t *testing.T) {
}
func TestPinningProxy_Forward_BlockedByDecide(t *testing.T) {
p := newPinningProxy(nil, nil, false, false)
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)
}
@@ -159,7 +187,7 @@ func TestPinningProxy_Forward_Bypass(t *testing.T) {
upstreamURL := mustParseURL(t, upstream.URL)
var bypassCalls atomic.Int32
p := newPinningProxy(nil, nil, false, false)
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
}
@@ -208,7 +236,7 @@ func TestPinningProxy_Forward_StripsHopByHopHeaders(t *testing.T) {
t.Cleanup(upstream.Close)
upstreamURL := mustParseURL(t, upstream.URL)
p := newPinningProxy(nil, nil, false, false)
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
}
@@ -248,7 +276,7 @@ func TestPinningProxy_Forward_StripsHopByHopHeaders(t *testing.T) {
}
func TestPinningProxy_Forward_RejectsNonAbsoluteURL(t *testing.T) {
p := newPinningProxy(nil, nil, false, false)
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
@@ -288,7 +316,7 @@ func TestPinningProxy_CONNECT_Pinned_Success(t *testing.T) {
t.Cleanup(stop)
var decideCalls atomic.Int32
p := newPinningProxy(nil, nil, false, false)
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
@@ -358,7 +386,7 @@ func TestPinningProxy_CONNECT_Pinned_Success(t *testing.T) {
}
func TestPinningProxy_CONNECT_BlockedByDecide(t *testing.T) {
p := newPinningProxy(nil, nil, false, false)
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)
}
@@ -417,7 +445,7 @@ func TestPinningProxy_DNSRebind_SingleResolution(t *testing.T) {
return gotenberg.OutboundDecision{}, fmt.Errorf("rebind lookup: %w", gotenberg.ErrFiltered)
}
p := newPinningProxy(nil, nil, false, false)
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" {
@@ -452,8 +480,395 @@ func TestPinningProxy_DNSRebind_SingleResolution(t *testing.T) {
}
}
// 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)
p := newPinningProxy(nil, nil, false, false, false)
err := p.Start(testLogger())
if err != nil {
t.Fatalf("first Start: %v", err)
@@ -467,7 +882,7 @@ func TestPinningProxy_StartTwice(t *testing.T) {
}
func TestPinningProxy_StopIdempotent(t *testing.T) {
p := newPinningProxy(nil, nil, false, false)
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)

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

@@ -341,11 +341,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.
@@ -388,16 +389,18 @@ 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
@@ -443,7 +446,7 @@ 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)
@@ -451,6 +454,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
var url string
err := form.
@@ -474,7 +478,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate stamp: %w", err)
}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, 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 URL to PDF: %w", err)
}
@@ -531,7 +535,7 @@ 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)
@@ -539,6 +543,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
var inputPath string
err := form.
@@ -559,7 +564,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
url := fmt.Sprintf("file://%s", inputPath)
options.AllowedFilePrefixes = []string{ctx.DirPath()}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, 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)
}
@@ -613,7 +618,7 @@ 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)
@@ -621,6 +626,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
var (
inputPath string
@@ -650,7 +656,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, embedsMetadata, 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)
}
@@ -775,7 +781,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, embedsMetadata map[string]map[string]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)
@@ -837,7 +843,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
}
@@ -862,6 +878,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)
@@ -884,7 +902,12 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
return fmt.Errorf("set embeds metadata: %w", err)
}
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
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

@@ -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

@@ -10,8 +10,10 @@ import (
"os/exec"
"regexp"
"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"
@@ -159,6 +161,9 @@ func buildExifToolWriteArgs(metadata map[string]any) ([]string, error) {
// [gotenberg.PdfEngine] interface.
type ExifTool struct {
binPath string
version string
versionOnce sync.Once
}
// Descriptor returns [ExifTool]'s module descriptor.
@@ -193,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()
@@ -226,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()
@@ -240,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()
@@ -254,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()
@@ -269,7 +301,7 @@ func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, format
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()
@@ -326,7 +358,7 @@ func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *slog.Logger, i
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()
@@ -371,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()
@@ -424,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()
@@ -438,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()
@@ -449,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()
@@ -466,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()
@@ -480,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()
@@ -494,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()
@@ -508,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()
@@ -523,6 +555,16 @@ func (engine *ExifTool) EmbedFilesMetadata(ctx context.Context, logger *slog.Log
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

@@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"strings"
"sync"
"syscall"
"time"
@@ -36,7 +37,7 @@ var (
ErrUnoException = errors.New("uno exception")
// ErrRuntimeException happens when unoconverter returns exit code 6.
ErrRuntimeException = errors.New("uno exception")
ErrRuntimeException = errors.New("runtime exception")
// ErrCoreDumped happens randomly; sometimes a conversion will work as
// expected, and some other time the same conversion will fail.
@@ -53,11 +54,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.
@@ -331,6 +336,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
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
}(),
@@ -358,10 +364,11 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
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"),
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"),
},
}
@@ -370,7 +377,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()
@@ -468,6 +475,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
}
@@ -477,12 +493,12 @@ 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))
}
return err
@@ -530,19 +546,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.
@@ -618,76 +661,123 @@ 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):
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,33 @@
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"},
{"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"
@@ -69,11 +70,13 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
// LibreOffice fetches external content (OOXML images via
// TargetMode=External, RTF INCLUDEPICTURE, ODT linked images) inside
// its own libcurl. Route those fetches through the in-process proxy
// so the chromium/webhook SSRF filters apply.
if err := writeSofficeProxyConfig(userProfileDirPath, proxy.Addr()); err != nil {
// 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 proxy config: %w", err)
return fmt.Errorf("write soffice profile config: %w", err)
}
sofficeEnv := sofficeProxyEnv(os.Environ(), proxy.Addr())
@@ -195,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")
@@ -203,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)
}
}
}
@@ -319,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))

View File

@@ -15,16 +15,18 @@ import (
"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
allowList []*regexp2.Regexp
denyList []*regexp2.Regexp
denyPrivateIPs bool
denyPublicIPs bool
enableEnvironmentProxy bool
}
// libreOfficeProxy is an HTTP/HTTPS forward proxy that LibreOffice routes
@@ -45,6 +47,12 @@ type libreOfficeProxy struct {
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
}
@@ -64,10 +72,15 @@ func newLibreOfficeProxy(logger *slog.Logger, opts outboundProxyOptions) (*libre
p := &libreOfficeProxy{
listener: listener,
client: gotenberg.NewOutboundHttpClient(0, opts.allowList, opts.denyList, decideOpts...),
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,
@@ -182,8 +195,25 @@ func (p *libreOfficeProxy) handleConnect(w http.ResponseWriter, r *http.Request)
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:
@@ -245,13 +275,26 @@ var hopByHopHeaders = []string{
"Upgrade",
}
// sofficeProxyConfigTmpl is the registrymodifications.xcu fragment that
// tells soffice's UCB layer to route every HTTP and HTTPS fetch through
// proxyHost:proxyPort. The %s placeholders accept the proxy host and
// port respectively (host first, port second, repeated for HTTP and
// HTTPS).
const sofficeProxyConfigTmpl = `<?xml version="1.0" encoding="UTF-8"?>
// 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>
@@ -261,10 +304,11 @@ const sofficeProxyConfigTmpl = `<?xml version="1.0" encoding="UTF-8"?>
</oor:items>
`
// writeSofficeProxyConfig drops a registrymodifications.xcu file into
// writeSofficeProfileConfig drops a registrymodifications.xcu file into
// userProfileDirPath/user/ that points soffice's UCB layer at proxyAddr
// for both HTTP and HTTPS. proxyAddr must be a host:port pair.
func writeSofficeProxyConfig(userProfileDirPath, proxyAddr string) error {
// 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)
@@ -276,7 +320,7 @@ func writeSofficeProxyConfig(userProfileDirPath, proxyAddr string) error {
return fmt.Errorf("create soffice user profile directory: %w", err)
}
body := fmt.Sprintf(sofficeProxyConfigTmpl, host, port, host, port)
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)

View File

@@ -280,11 +280,11 @@ func TestLibreOfficeProxy_StopIsIdempotent(t *testing.T) {
}
}
func TestWriteSofficeProxyConfig(t *testing.T) {
func TestWriteSofficeProfileConfig(t *testing.T) {
dir := t.TempDir()
if err := writeSofficeProxyConfig(dir, "127.0.0.1:9876"); err != nil {
t.Fatalf("writeSofficeProxyConfig: %v", err)
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"))
@@ -297,6 +297,9 @@ func TestWriteSofficeProxyConfig(t *testing.T) {
`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)
@@ -304,8 +307,8 @@ func TestWriteSofficeProxyConfig(t *testing.T) {
}
}
func TestWriteSofficeProxyConfig_InvalidAddr(t *testing.T) {
err := writeSofficeProxyConfig(t.TempDir(), "not-a-host-port")
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")
}

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)
}
@@ -135,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

@@ -30,7 +30,7 @@ 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)
@@ -38,6 +38,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
stampFile := pdfengines.FormDataPdfStampFile(form)
angle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
zeroValuedSplitMode := gotenberg.SplitMode{}
@@ -313,13 +314,27 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
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 {
@@ -385,7 +400,7 @@ 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),
),
)
}
@@ -503,7 +518,12 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
return fmt.Errorf("set embeds metadata: %w", err)
}
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
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

@@ -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()
@@ -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,7 +303,7 @@ 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()
@@ -376,7 +406,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()
@@ -452,12 +482,22 @@ func (engine *PdfCpu) EmbedFilesMetadata(ctx context.Context, logger *slog.Logge
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()
@@ -493,30 +533,38 @@ 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 = append(args, "encrypt")
args = append(args, "--mode", "aes")
args = append(args, "--upw", userPassword)
args = append(args, "--upw", opts.UserPassword)
args = append(args, "--opw", ownerPassword)
args = append(args, "--perm", "all")
args = append(args, "--perm", perm)
args = append(args, inputPath, inputPath)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
@@ -543,7 +591,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()
@@ -562,7 +610,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()
@@ -581,7 +629,7 @@ 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()

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"
@@ -28,6 +28,7 @@ type multiPdfEngines struct {
watermarkEngines []gotenberg.PdfEngine
stampEngines []gotenberg.PdfEngine
rotateEngines []gotenberg.PdfEngine
facturXEngines []gotenberg.PdfEngine
}
func newMultiPdfEngines(
@@ -44,7 +45,8 @@ func newMultiPdfEngines(
writeBookmarksEngines,
watermarkEngines,
stampEngines,
rotateEngines []gotenberg.PdfEngine,
rotateEngines,
facturXEngines []gotenberg.PdfEngine,
) *multiPdfEngines {
return &multiPdfEngines{
mergeEngines: mergeEngines,
@@ -61,586 +63,292 @@ func newMultiPdfEngines(
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()
var err error
errChan := make(chan error, 1)
for _, engine := range multi.rotateEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.Rotate(ctx, logger, inputPath, angle, pages)
}(engine)
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()
}
}
err = fmt.Errorf("rotate PDF with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
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) },
)
}
// EmbedFilesMetadata sets metadata on embedded files using the first available
// engine that supports it.
//
//nolint:dupl
func (multi *multiPdfEngines) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.EmbedFilesMetadata", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
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) },
)
}
var err error
errChan := make(chan error, 1)
// 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) },
)
}
for _, engine := range multi.embedMetadataEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.EmbedFilesMetadata(ctx, logger, metadata, inputPath)
}(engine)
select {
case setErr := <-errChan:
if setErr != nil {
err = errors.Join(err, setErr)
} 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("set embeds metadata using 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

@@ -42,6 +42,7 @@ type PdfEngines struct {
watermarkNames []string
stampNames []string
rotateNames []string
facturXNames []string
engines []gotenberg.PdfEngine
disableRoutes bool
}
@@ -66,6 +67,7 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
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.
@@ -99,6 +101,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
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))
@@ -195,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
}
@@ -203,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))
@@ -250,6 +258,7 @@ func (mod *PdfEngines) Validate() error {
findNonExistingEngines(mod.watermarkNames)
findNonExistingEngines(mod.stampNames)
findNonExistingEngines(mod.rotateNames)
findNonExistingEngines(mod.facturXNames)
if len(nonExistingEngines) == 0 {
return nil
@@ -276,6 +285,7 @@ func (mod *PdfEngines) SystemMessages() []string {
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, " ")),
}
}
@@ -310,6 +320,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
engines(mod.watermarkNames),
engines(mod.stampNames),
engines(mod.rotateNames),
engines(mod.facturXNames),
), nil
}
@@ -340,6 +351,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
watermarkRoute(engine),
stampRoute(engine),
rotateRoute(engine),
facturXRoute(engine),
}, nil
}

View File

@@ -467,21 +467,244 @@ func EmbedFilesMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metada
return nil
}
// 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
// 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
}
// EncryptPdfStub adds password protection to PDF files.
func EncryptPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, userPassword, ownerPassword string, inputPaths []string) error {
if userPassword == "" {
// 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.Encrypt(ctx, ctx.Log(), inputPath, userPassword, ownerPassword)
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)
}
@@ -699,7 +922,7 @@ 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)
@@ -707,6 +930,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
stampFile := FormDataPdfStampFile(form)
angle, rotatePages := FormDataPdfRotate(form, false)
embedsMetadata := FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := FormDataPdfFacturX(form)
var inputPaths []string
var flatten bool
@@ -729,7 +953,17 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
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
}
@@ -764,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)
@@ -830,7 +1066,12 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("set embeds metadata: %w", err)
}
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
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)
}
@@ -858,7 +1099,7 @@ 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)
@@ -866,6 +1107,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
stampFile := FormDataPdfStampFile(form)
angle, rotatePages := FormDataPdfRotate(form, false)
embedsMetadata := FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := FormDataPdfFacturX(form)
var inputPaths []string
var flatten bool
@@ -886,7 +1128,17 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
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
}
@@ -918,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)
@@ -940,7 +1194,12 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("set embeds metadata: %w", err)
}
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
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)
}
@@ -1224,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)
}
@@ -1265,6 +1530,7 @@ 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.
@@ -1273,17 +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 = EmbedFilesMetadataStub(ctx, engine, embedsMetadata, inputPaths)
err = EmbedFilesMetadataStub(ctx, engine, embedsMetadata, outputPaths)
if err != nil {
return fmt.Errorf("set embeds metadata: %w", err)
}
err = ctx.AddOutputPaths(inputPaths...)
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)
}
@@ -1413,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()
@@ -500,6 +531,16 @@ func (engine *PdfTk) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger
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)
}
})
}
}

View File

@@ -0,0 +1,299 @@
package qpdf
import (
"errors"
"strings"
"testing"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestValidateFacturX(t *testing.T) {
valid := gotenberg.FacturX{
ConformanceLevel: gotenberg.FacturXConformanceEN16931,
DocumentType: gotenberg.FacturXDocumentTypeInvoice,
DocumentFileName: "factur-x.xml",
Version: "1.0",
}
tests := []struct {
name string
mutate func(f *gotenberg.FacturX)
wantError bool
}{
{name: "valid", mutate: func(*gotenberg.FacturX) {}},
{
name: "valid BASIC WL conformance",
mutate: func(f *gotenberg.FacturX) { f.ConformanceLevel = gotenberg.FacturXConformanceBasicWL },
},
{
name: "valid ORDER document type",
mutate: func(f *gotenberg.FacturX) { f.DocumentType = gotenberg.FacturXDocumentTypeOrder },
},
{
name: "unsupported conformance level",
mutate: func(f *gotenberg.FacturX) { f.ConformanceLevel = "FOO" },
wantError: true,
},
{
name: "empty conformance level",
mutate: func(f *gotenberg.FacturX) { f.ConformanceLevel = "" },
wantError: true,
},
{
name: "unsupported document type",
mutate: func(f *gotenberg.FacturX) { f.DocumentType = "RECEIPT" },
wantError: true,
},
{
name: "empty document file name",
mutate: func(f *gotenberg.FacturX) { f.DocumentFileName = "" },
wantError: true,
},
{
name: "empty version",
mutate: func(f *gotenberg.FacturX) { f.Version = "" },
wantError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
facturX := valid
tt.mutate(&facturX)
err := validateFacturX(facturX)
if tt.wantError {
if err == nil {
t.Fatal("expected an error, got nil")
}
if !errors.Is(err, gotenberg.ErrPdfFacturXValueNotSupported) {
t.Errorf("expected ErrPdfFacturXValueNotSupported, got %v", err)
}
return
}
if err != nil {
t.Errorf("expected no error, got %v", err)
}
})
}
}
func TestFindMetadataStream(t *testing.T) {
tests := []struct {
name string
input string
wantKey string
wantXMP string
wantError bool
}{
{
name: "metadata stream found",
// "PFhtcD4=" is base64 for "<xmp>".
input: `{"qpdf":[{},{"obj:1 0 R":{"value":{"/Type":"/Catalog","/Metadata":"4 0 R"}},"obj:4 0 R":{"stream":{"dict":{"/Type":"/Metadata","/Subtype":"/XML"},"data":"PHhtcD4="}}}]}`,
wantKey: "obj:4 0 R",
wantXMP: "<xmp>",
},
{
name: "catalog without metadata reference",
input: `{"qpdf":[{},{"obj:1 0 R":{"value":{"/Type":"/Catalog"}}}]}`,
wantError: true,
},
{
name: "metadata object missing",
input: `{"qpdf":[{},{"obj:1 0 R":{"value":{"/Type":"/Catalog","/Metadata":"4 0 R"}}}]}`,
wantError: true,
},
{
name: "metadata object is not a stream",
input: `{"qpdf":[{},{"obj:1 0 R":{"value":{"/Type":"/Catalog","/Metadata":"4 0 R"}},"obj:4 0 R":{"value":{"/Type":"/Metadata"}}}]}`,
wantError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
objects, err := parsePdfObjects([]byte(tt.input))
if err != nil {
t.Fatalf("parse objects: %v", err)
}
key, dict, xmp, err := findMetadataStream(objects)
if tt.wantError {
if err == nil {
t.Fatal("expected an error, got nil")
}
return
}
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if key != tt.wantKey {
t.Errorf("key = %q, want %q", key, tt.wantKey)
}
if xmp != tt.wantXMP {
t.Errorf("xmp = %q, want %q", xmp, tt.wantXMP)
}
if dict == nil {
t.Error("expected a non-nil dict")
}
})
}
}
func TestInjectFacturXIntoXMP(t *testing.T) {
facturX := gotenberg.FacturX{
ConformanceLevel: gotenberg.FacturXConformanceEN16931,
DocumentType: gotenberg.FacturXDocumentTypeInvoice,
DocumentFileName: "factur-x.xml",
Version: "1.0",
}
// The packet a LibreOffice PDF/A-3b export produces: no pdfaExtension bag.
libreOfficeXMP := `<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/">
<pdfaid:part>3</pdfaid:part>
<pdfaid:conformance>B</pdfaid:conformance>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>`
t.Run("creates extension schema when bag absent", func(t *testing.T) {
got, changed := injectFacturXIntoXMP(libreOfficeXMP, facturX)
if !changed {
t.Fatal("expected changed = true")
}
assertContains(t, got, facturXNamespaceURI)
assertContains(t, got, "<fx:ConformanceLevel>EN 16931</fx:ConformanceLevel>")
assertContains(t, got, "<fx:DocumentType>INVOICE</fx:DocumentType>")
assertContains(t, got, "<fx:DocumentFileName>factur-x.xml</fx:DocumentFileName>")
assertContains(t, got, "<fx:Version>1.0</fx:Version>")
assertContains(t, got, "pdfaExtension:schemas")
assertContains(t, got, "http://www.aiim.org/pdfa/ns/extension/")
// The fx blocks must land inside the RDF container.
if strings.Index(got, facturXNamespaceURI) > strings.LastIndex(got, "</rdf:RDF>") {
t.Error("fx content injected outside the rdf:RDF container")
}
})
t.Run("idempotent when fx already present", func(t *testing.T) {
once, _ := injectFacturXIntoXMP(libreOfficeXMP, facturX)
twice, changed := injectFacturXIntoXMP(once, facturX)
if changed {
t.Error("expected changed = false on a packet that already declares fx")
}
if twice != once {
t.Error("expected the packet to be left untouched")
}
if strings.Count(twice, "<pdfaExtension:schemas>") != 1 {
t.Error("expected exactly one pdfaExtension:schemas declaration")
}
})
t.Run("appends entry to an existing bag", func(t *testing.T) {
withBag := `<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#" xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#">
<pdfaExtension:schemas>
<rdf:Bag>
<rdf:li rdf:parseType="Resource"><pdfaSchema:prefix>other</pdfaSchema:prefix></rdf:li>
</rdf:Bag>
</pdfaExtension:schemas>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>`
got, changed := injectFacturXIntoXMP(withBag, facturX)
if !changed {
t.Fatal("expected changed = true")
}
assertContains(t, got, facturXNamespaceURI)
if strings.Count(got, "<pdfaExtension:schemas>") != 1 {
t.Error("expected the existing pdfaExtension:schemas bag to be reused, not duplicated")
}
if strings.Count(got, "<pdfaSchema:prefix>") != 2 {
t.Error("expected both the existing and the fx schema entries")
}
})
t.Run("no rdf:RDF anchor leaves packet unchanged", func(t *testing.T) {
got, changed := injectFacturXIntoXMP("not an xmp packet", facturX)
if changed {
t.Error("expected changed = false")
}
if got != "not an xmp packet" {
t.Error("expected the packet to be left untouched")
}
})
t.Run("escapes runtime values", func(t *testing.T) {
escaped := facturX
escaped.DocumentFileName = "a&b<c>.xml"
got, _ := injectFacturXIntoXMP(libreOfficeXMP, escaped)
assertContains(t, got, "a&amp;b&lt;c&gt;.xml")
if strings.Contains(got, "<fx:DocumentFileName>a&b<c>.xml") {
t.Error("expected the document file name to be XML-escaped")
}
})
}
func assertContains(t *testing.T, haystack, needle string) {
t.Helper()
if !strings.Contains(haystack, needle) {
t.Errorf("expected output to contain %q", needle)
}
}
func TestParsePdfAId(t *testing.T) {
for _, tc := range []struct {
scenario string
xmp string
expectPart string
expectConform string
}{
{
scenario: "element form",
xmp: `<rdf:Description><pdfaid:part>3</pdfaid:part><pdfaid:conformance>B</pdfaid:conformance></rdf:Description>`,
expectPart: "3",
expectConform: "B",
},
{
scenario: "attribute form",
xmp: `<rdf:Description pdfaid:part="3" pdfaid:conformance="U"></rdf:Description>`,
expectPart: "3",
expectConform: "U",
},
{
scenario: "part 2",
xmp: `<pdfaid:part>2</pdfaid:part><pdfaid:conformance>B</pdfaid:conformance>`,
expectPart: "2",
expectConform: "B",
},
{
scenario: "no pdfa identification",
xmp: `<rdf:Description><dc:title>foo</dc:title></rdf:Description>`,
expectPart: "",
expectConform: "",
},
{
scenario: "empty packet",
xmp: "",
expectPart: "",
expectConform: "",
},
} {
t.Run(tc.scenario, func(t *testing.T) {
part, conformance := parsePdfAId(tc.xmp)
if part != tc.expectPart {
t.Errorf("expected part %q but got %q", tc.expectPart, part)
}
if conformance != tc.expectConform {
t.Errorf("expected conformance %q but got %q", tc.expectConform, conformance)
}
})
}
}

View File

@@ -3,16 +3,21 @@ package qpdf
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"regexp"
"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"
@@ -29,6 +34,9 @@ func init() {
type QPdf struct {
binPath string
globalArgs []string
version string
versionOnce sync.Once
}
// Descriptor returns a [QPdf]'s module descriptor.
@@ -43,7 +51,7 @@ func (engine *QPdf) Descriptor() gotenberg.ModuleDescriptor {
func (engine *QPdf) Provision(ctx *gotenberg.Context) error {
binPath, ok := os.LookupEnv("QPDF_BIN_PATH")
if !ok {
return errors.New("QPDF_BIN_PATH environment variable is not set")
return errors.New("QPDF_BIN_PATH environment variable is not set; set it to the absolute path of the qpdf binary")
}
engine.binPath = binPath
@@ -65,32 +73,58 @@ func (engine *QPdf) Validate() error {
// Debug returns additional debug data.
func (engine *QPdf) 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 qpdf version once, preferring the value captured
// at image build time so it never spawns qpdf at runtime. It falls back to
// running qpdf --version for local or non-Docker builds.
func (engine *QPdf) detectVersion() string {
engine.versionOnce.Do(func() {
if v, ok := gotenberg.BuildVersion("qpdf"); 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 QPDF version"
})
return engine.version
}
// spanAttrs returns the client-span attributes for a qpdf invocation: the
// server address and the qpdf version, plus any extra attributes. The version
// rides on every span so a trace records which qpdf ran the operation.
func (engine *QPdf) 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.qpdf.version", v))
}
lines := bytes.SplitN(output, []byte("\n"), 2)
if len(lines) > 0 {
debug["version"] = string(lines[0])
} else {
debug["version"] = "Unable to determine QPDF version"
}
return debug
return append(attrs, extra...)
}
// Split splits a given PDF file.
func (engine *QPdf) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Split",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -140,7 +174,7 @@ func (engine *QPdf) Split(ctx context.Context, logger *slog.Logger, mode gotenbe
func (engine *QPdf) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Merge",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -176,7 +210,7 @@ func (engine *QPdf) Merge(ctx context.Context, logger *slog.Logger, inputPaths [
func (engine *QPdf) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Flatten",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -211,7 +245,7 @@ func (engine *QPdf) Flatten(ctx context.Context, logger *slog.Logger, inputPath
func (engine *QPdf) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Convert",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -225,7 +259,7 @@ func (engine *QPdf) Convert(ctx context.Context, logger *slog.Logger, formats go
func (engine *QPdf) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
_, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadMetadata",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -239,7 +273,7 @@ func (engine *QPdf) ReadMetadata(ctx context.Context, logger *slog.Logger, input
func (engine *QPdf) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "qpdf.WriteMetadata",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -253,7 +287,7 @@ func (engine *QPdf) WriteMetadata(ctx context.Context, logger *slog.Logger, meta
func (engine *QPdf) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
_, span := gotenberg.Tracer().Start(ctx, "qpdf.PageCount",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -267,7 +301,7 @@ func (engine *QPdf) PageCount(ctx context.Context, logger *slog.Logger, inputPat
func (engine *QPdf) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
_, span := gotenberg.Tracer().Start(ctx, "qpdf.WriteBookmarks",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -281,7 +315,7 @@ func (engine *QPdf) WriteBookmarks(ctx context.Context, logger *slog.Logger, inp
func (engine *QPdf) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
_, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadBookmarks",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -292,29 +326,63 @@ func (engine *QPdf) ReadBookmarks(ctx context.Context, logger *slog.Logger, inpu
}
// Encrypt adds password protection to a PDF file using QPDF.
func (engine *QPdf) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
// qpdfPermissionArgs maps PDF permissions to QPDF --encrypt restriction flags.
// It returns nil when every permission is allowed, matching QPDF's default
// (all actions permitted).
func qpdfPermissionArgs(p gotenberg.PdfPermissions) []string {
if !p.Restricted() {
return nil
}
yn := func(allowed bool) string {
if allowed {
return "y"
}
return "n"
}
print := "full"
if !p.AllowPrinting {
print = "none"
}
return []string{
"--print=" + print,
"--extract=" + yn(p.AllowCopying),
"--modify-other=" + yn(p.AllowModifying),
"--annotate=" + yn(p.AllowAnnotating),
"--form=" + yn(p.AllowFillingForms),
"--assemble=" + yn(p.AllowAssembling),
}
}
func (engine *QPdf) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.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
}
args := make([]string, 0, 7+len(engine.globalArgs))
args := make([]string, 0, 14+len(engine.globalArgs))
args = append(args, inputPath)
args = append(args, engine.globalArgs...)
args = append(args, "--replace-input")
args = append(args, "--encrypt", userPassword, ownerPassword, "256", "--")
args = append(args, "--encrypt", opts.UserPassword, ownerPassword, "256")
args = append(args, qpdfPermissionArgs(opts.Permissions)...)
args = append(args, "--")
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
@@ -340,7 +408,7 @@ func (engine *QPdf) Encrypt(ctx context.Context, logger *slog.Logger, inputPath,
func (engine *QPdf) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "qpdf.EmbedFiles",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -357,7 +425,7 @@ func (engine *QPdf) EmbedFiles(ctx context.Context, logger *slog.Logger, filePat
func (engine *QPdf) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.EmbedFilesMetadata",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -527,8 +595,10 @@ func patchCatalogAF(catalogRef string, catalogValue map[string]any, filespecRefs
}
// writeAndApplyUpdate marshals the update objects as QPDF JSON v2, writes
// them to a temp file, and applies the update via --update-from-json.
func (engine *QPdf) writeAndApplyUpdate(ctx context.Context, logger *slog.Logger, inputPath string, updateObjects map[string]any) error {
// them to a temp file, and applies the update via --update-from-json. extraArgs
// are appended to the QPDF command (e.g., --json-stream-data=inline when the
// update replaces stream data).
func (engine *QPdf) writeAndApplyUpdate(ctx context.Context, logger *slog.Logger, inputPath string, updateObjects map[string]any, extraArgs ...string) error {
updateJSON := map[string]any{
"qpdf": []any{
map[string]any{
@@ -560,9 +630,10 @@ func (engine *QPdf) writeAndApplyUpdate(ctx context.Context, logger *slog.Logger
return fmt.Errorf("close temp file: %w", err)
}
updateArgs := make([]string, 0, 5+len(engine.globalArgs))
updateArgs := make([]string, 0, 5+len(engine.globalArgs)+len(extraArgs))
updateArgs = append(updateArgs, inputPath)
updateArgs = append(updateArgs, engine.globalArgs...)
updateArgs = append(updateArgs, extraArgs...)
updateArgs = append(updateArgs, "--newline-before-endstream")
updateArgs = append(updateArgs, "--update-from-json="+tmpFile.Name())
updateArgs = append(updateArgs, "--replace-input")
@@ -635,11 +706,396 @@ func stripQpdfStringPrefix(s string) string {
return s
}
// facturXNamespaceURI is the Factur-X/ZUGFeRD XMP namespace required by strict
// validators.
const facturXNamespaceURI = "urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#"
// InjectFacturXXMP injects Factur-X/ZUGFeRD XMP metadata into the document-level
// XMP packet (Catalog /Metadata stream) of a PDF/A-3 using QPDF's JSON
// manipulation. It reads the existing XMP packet, splices in the fx
// rdf:Description plus the PDF/A extension-schema declaration, and writes the
// stream back uncompressed so the document stays PDF/A-valid.
//
// It assumes the input already carries a Catalog /Metadata stream (always true
// for a LibreOffice PDF/A export). The injection is idempotent: a packet that
// already declares the fx namespace is left untouched.
func (engine *QPdf) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.InjectFacturXXMP",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(engine.spanAttrs(
attribute.String("gotenberg.facturx.conformance_level", facturX.ConformanceLevel),
attribute.String("gotenberg.facturx.document_type", facturX.DocumentType),
)...),
)
defer span.End()
err := validateFacturX(facturX)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
logger.DebugContext(ctx, fmt.Sprintf("injecting Factur-X XMP into %s with QPDF", inputPath))
args := append([]string{inputPath}, engine.globalArgs...)
args = append(args, "--newline-before-endstream", "--json-output", "--json-stream-data=inline")
output, err := engine.execCaptureOutput(ctx, args...)
if err != nil {
err = fmt.Errorf("get PDF JSON with QPDF: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
objects, err := parsePdfObjects(output)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
metaKey, metaDict, xmp, err := findMetadataStream(objects)
if err != nil {
err = fmt.Errorf("locate XMP metadata stream: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
newXmp, changed := injectFacturXIntoXMP(xmp, facturX)
if !changed {
logger.DebugContext(ctx, "Factur-X XMP already present, skipping injection")
span.SetStatus(codes.Ok, "")
return nil
}
// PDF/A requires the metadata stream to be uncompressed and unfiltered. We
// provide the decoded XMP as the new stream data, so any existing filter
// must be dropped and the length left for QPDF to recompute.
delete(metaDict, "/Filter")
delete(metaDict, "/DecodeParms")
delete(metaDict, "/Length")
updateObjects := map[string]any{
metaKey: map[string]any{
"stream": map[string]any{
"dict": metaDict,
"data": base64.StdEncoding.EncodeToString([]byte(newXmp)),
},
},
}
err = engine.writeAndApplyUpdate(ctx, logger, inputPath, updateObjects, "--json-stream-data=inline")
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
span.SetStatus(codes.Ok, "")
return nil
}
// ReadPdfAConformance reads the PDF/A part and conformance from the
// document-level XMP packet (pdfaid:part and pdfaid:conformance) using QPDF's
// JSON output. It returns empty strings when the document carries no XMP
// metadata stream or no PDF/A identification.
func (engine *QPdf) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadPdfAConformance",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
logger.DebugContext(ctx, fmt.Sprintf("reading PDF/A conformance from %s with QPDF", inputPath))
args := append([]string{inputPath}, engine.globalArgs...)
args = append(args, "--json-output", "--json-stream-data=inline")
output, err := engine.execCaptureOutput(ctx, args...)
if err != nil {
err = fmt.Errorf("get PDF JSON with QPDF: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return "", "", err
}
objects, err := parsePdfObjects(output)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return "", "", err
}
_, _, xmp, err := findMetadataStream(objects)
if err != nil {
// No XMP metadata stream means no PDF/A identification.
logger.DebugContext(ctx, fmt.Sprintf("no XMP metadata stream in %s: %s", inputPath, err))
span.SetStatus(codes.Ok, "")
return "", "", nil
}
part, conformance := parsePdfAId(xmp)
span.SetAttributes(
attribute.String("gotenberg.pdfa.part", part),
attribute.String("gotenberg.pdfa.conformance", conformance),
)
span.SetStatus(codes.Ok, "")
return part, conformance, nil
}
var (
pdfaIdPartRe = regexp.MustCompile(`pdfaid:part[\s>="']*([0-9]+)`)
pdfaIdConformanceRe = regexp.MustCompile(`pdfaid:conformance[\s>="']*([A-Za-z]+)`)
)
// parsePdfAId extracts the PDF/A part and conformance from an XMP packet. It
// handles both the element (<pdfaid:part>3</pdfaid:part>) and attribute
// (pdfaid:part="3") serializations.
func parsePdfAId(xmp string) (part string, conformance string) {
if m := pdfaIdPartRe.FindStringSubmatch(xmp); m != nil {
part = m[1]
}
if m := pdfaIdConformanceRe.FindStringSubmatch(xmp); m != nil {
conformance = m[1]
}
return part, conformance
}
// validateFacturX checks the Factur-X fields against the supported values.
func validateFacturX(facturX gotenberg.FacturX) error {
switch facturX.ConformanceLevel {
case gotenberg.FacturXConformanceMinimum,
gotenberg.FacturXConformanceBasicWL,
gotenberg.FacturXConformanceBasic,
gotenberg.FacturXConformanceEN16931,
gotenberg.FacturXConformanceExtended,
gotenberg.FacturXConformanceXRechnung:
default:
return fmt.Errorf("conformance level '%s': %w", facturX.ConformanceLevel, gotenberg.ErrPdfFacturXValueNotSupported)
}
switch facturX.DocumentType {
case gotenberg.FacturXDocumentTypeInvoice,
gotenberg.FacturXDocumentTypeOrder,
gotenberg.FacturXDocumentTypeOrderResponse,
gotenberg.FacturXDocumentTypeOrderChange:
default:
return fmt.Errorf("document type '%s': %w", facturX.DocumentType, gotenberg.ErrPdfFacturXValueNotSupported)
}
if facturX.DocumentFileName == "" {
return fmt.Errorf("document file name is empty: %w", gotenberg.ErrPdfFacturXValueNotSupported)
}
if facturX.Version == "" {
return fmt.Errorf("version is empty: %w", gotenberg.ErrPdfFacturXValueNotSupported)
}
return nil
}
// findMetadataStream locates the document-level XMP metadata stream referenced
// by the Catalog /Metadata entry. It returns the object key (e.g. "obj:4 0 R"),
// the stream dict, and the decoded XMP packet.
func findMetadataStream(objects map[string]json.RawMessage) (string, map[string]any, string, error) {
var metadataRef string
for _, raw := range objects {
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
continue
}
valueRaw, ok := obj["value"]
if !ok {
continue
}
var value map[string]any
if err := json.Unmarshal(valueRaw, &value); err != nil {
continue
}
if typeVal, _ := value["/Type"].(string); typeVal == "/Catalog" {
metadataRef, _ = value["/Metadata"].(string)
break
}
}
if metadataRef == "" {
return "", nil, "", errors.New("no /Metadata reference in the catalog")
}
// References in values use the "4 0 R" form; object keys use "obj:4 0 R".
objKey := "obj:" + metadataRef
raw, ok := objects[objKey]
if !ok {
return "", nil, "", fmt.Errorf("metadata object '%s' not found", objKey)
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
return "", nil, "", fmt.Errorf("unmarshal metadata object: %w", err)
}
streamRaw, ok := obj["stream"]
if !ok {
return "", nil, "", errors.New("metadata object is not a stream")
}
var stream struct {
Dict map[string]any `json:"dict"`
Data string `json:"data"`
}
if err := json.Unmarshal(streamRaw, &stream); err != nil {
return "", nil, "", fmt.Errorf("unmarshal metadata stream: %w", err)
}
decoded, err := base64.StdEncoding.DecodeString(stream.Data)
if err != nil {
return "", nil, "", fmt.Errorf("decode metadata stream data: %w", err)
}
dict := stream.Dict
if dict == nil {
dict = make(map[string]any)
}
return objKey, dict, string(decoded), nil
}
// injectFacturXIntoXMP splices the fx rdf:Description and the PDF/A
// extension-schema declaration into an XMP packet. It returns the new packet and
// whether a change was made (false when the fx namespace is already present).
func injectFacturXIntoXMP(xmp string, facturX gotenberg.FacturX) (string, bool) {
if strings.Contains(xmp, facturXNamespaceURI) {
return xmp, false
}
anchor := strings.LastIndex(xmp, "</rdf:RDF>")
if anchor == -1 {
return xmp, false
}
insert := facturXDescription(facturX)
if strings.Contains(xmp, "pdfaExtension:schemas") {
// An extension-schema bag already exists (e.g. emitted by another tool):
// splice the fx Description, then append the fx schema entry into the bag.
spliced := xmp[:anchor] + insert + xmp[anchor:]
return injectSchemaIntoExistingBag(spliced), true
}
// No extension-schema bag yet (the LibreOffice PDF/A case): create the whole
// container alongside the fx Description.
insert += facturXExtensionSchema()
return xmp[:anchor] + insert + xmp[anchor:], true
}
// injectSchemaIntoExistingBag appends the fx schema entry into an existing
// pdfaExtension:schemas bag.
func injectSchemaIntoExistingBag(xmp string) string {
mi := strings.Index(xmp, "pdfaExtension:schemas")
if mi == -1 {
return xmp
}
bag := strings.Index(xmp[mi:], "<rdf:Bag")
if bag == -1 {
return xmp
}
gt := strings.Index(xmp[mi+bag:], ">")
if gt == -1 {
return xmp
}
pos := mi + bag + gt + 1
return xmp[:pos] + "\n" + facturXSchemaLi() + xmp[pos:]
}
// facturXDescription builds the fx rdf:Description carrying the runtime values.
func facturXDescription(facturX gotenberg.FacturX) string {
return fmt.Sprintf(` <rdf:Description rdf:about="" xmlns:fx="%s">
<fx:DocumentType>%s</fx:DocumentType>
<fx:DocumentFileName>%s</fx:DocumentFileName>
<fx:Version>%s</fx:Version>
<fx:ConformanceLevel>%s</fx:ConformanceLevel>
</rdf:Description>
`,
facturXNamespaceURI,
xmlEscape(facturX.DocumentType),
xmlEscape(facturX.DocumentFileName),
xmlEscape(facturX.Version),
xmlEscape(facturX.ConformanceLevel),
)
}
// facturXExtensionSchema builds the rdf:Description that declares the PDF/A
// extension schema for the fx namespace, including the namespace declarations.
func facturXExtensionSchema() string {
return fmt.Sprintf(` <rdf:Description rdf:about="" xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#" xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#">
<pdfaExtension:schemas>
<rdf:Bag>
%s
</rdf:Bag>
</pdfaExtension:schemas>
</rdf:Description>
`, facturXSchemaLi())
}
// facturXSchemaLi builds the rdf:li describing the fx schema and its four
// properties. These are fixed schema definitions, not runtime invoice values.
func facturXSchemaLi() string {
return fmt.Sprintf(` <rdf:li rdf:parseType="Resource">
<pdfaSchema:schema>Factur-X PDFA Extension Schema</pdfaSchema:schema>
<pdfaSchema:namespaceURI>%s</pdfaSchema:namespaceURI>
<pdfaSchema:prefix>fx</pdfaSchema:prefix>
<pdfaSchema:property>
<rdf:Seq>
<rdf:li rdf:parseType="Resource">
<pdfaProperty:name>DocumentFileName</pdfaProperty:name>
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
<pdfaProperty:category>external</pdfaProperty:category>
<pdfaProperty:description>name of the embedded XML invoice file</pdfaProperty:description>
</rdf:li>
<rdf:li rdf:parseType="Resource">
<pdfaProperty:name>DocumentType</pdfaProperty:name>
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
<pdfaProperty:category>external</pdfaProperty:category>
<pdfaProperty:description>The type of the embedded Factur-X document</pdfaProperty:description>
</rdf:li>
<rdf:li rdf:parseType="Resource">
<pdfaProperty:name>Version</pdfaProperty:name>
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
<pdfaProperty:category>external</pdfaProperty:category>
<pdfaProperty:description>The actual version of the Factur-X XML schema</pdfaProperty:description>
</rdf:li>
<rdf:li rdf:parseType="Resource">
<pdfaProperty:name>ConformanceLevel</pdfaProperty:name>
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
<pdfaProperty:category>external</pdfaProperty:category>
<pdfaProperty:description>The conformance level of the embedded Factur-X data</pdfaProperty:description>
</rdf:li>
</rdf:Seq>
</pdfaSchema:property>
</rdf:li>`, facturXNamespaceURI)
}
// xmlEscape escapes a string for safe inclusion in XML character data.
func xmlEscape(s string) string {
var buf bytes.Buffer
_ = xml.EscapeText(&buf, []byte(s))
return buf.String()
}
// Watermark is not available in this implementation.
func (engine *QPdf) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Watermark",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -653,7 +1109,7 @@ func (engine *QPdf) Watermark(ctx context.Context, logger *slog.Logger, inputPat
func (engine *QPdf) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Stamp",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -667,7 +1123,7 @@ func (engine *QPdf) Stamp(ctx context.Context, logger *slog.Logger, inputPath st
func (engine *QPdf) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Rotate",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()

View File

@@ -0,0 +1,58 @@
package qpdf
import (
"os"
"path/filepath"
"testing"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestQPdfDetectVersion(t *testing.T) {
t.Run("prefers the build-time version without executing qpdf", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "qpdf"), []byte("qpdf version 11.9.0\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 qpdf.
engine := &QPdf{binPath: "/nonexistent/qpdf"}
if got := engine.Debug()["version"]; got != "qpdf version 11.9.0" {
t.Errorf("Debug()[version] = %v, want the build-time value", got)
}
})
t.Run("falls back to executing qpdf 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.
engine := &QPdf{binPath: "/nonexistent/qpdf"}
if got := engine.Debug()["version"]; got == "qpdf version 11.9.0" {
t.Errorf("Debug()[version] = %v, expected the exec fallback", got)
}
})
t.Run("exposes the version as a span attribute", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "qpdf"), []byte("qpdf version 12.2.0"), 0o600); err != nil {
t.Fatalf("write version file: %v", err)
}
t.Setenv(gotenberg.BuildVersionsDirPathEnvVar, dir)
// spanAttrs is what gets handed to trace.WithAttributes, so its output
// is exactly what a span carries.
engine := &QPdf{binPath: "/nonexistent/qpdf"}
var got string
for _, attr := range engine.spanAttrs() {
if attr.Key == "gotenberg.qpdf.version" {
got = attr.Value.AsString()
}
}
if got != "qpdf version 12.2.0" {
t.Errorf("span attribute gotenberg.qpdf.version = %q, want the build-time value", got)
}
})
}

View File

@@ -0,0 +1,62 @@
package webhook
import (
"context"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
)
// detachAsyncContext detaches ctx from the inbound request lifecycle so the
// webhook goroutine survives echo recycling the request, while preserving the
// conversion deadline and the caller's trace.
//
// Echo cancels the request context as soon as the synchronous handler returns
// [api.ErrAsyncProcess], which would abort the asynchronous work. Detaching via
// [context.WithoutCancel] severs that cancellation while keeping the context
// values. [context.WithoutCancel] also drops the deadline, so it is re-layered.
//
// The server span ends as soon as that handler returns, so its span context is
// re-seated as a remote, non-recording parent: the asynchronous worker keeps
// the same trace without recording into a span that is about to end. A
// worker-root [webhook.Async] span is then opened, linked back to the
// originating request span, and stays open for the whole delivery so downstream
// conversion and webhook spans have a live parent in the caller's trace.
//
// The returned cancel function ends the worker span and cleans up both the
// detached context and the original working directory.
func detachAsyncContext(ctx *api.Context, cancel context.CancelFunc) context.CancelFunc {
deadline, hasDeadline := ctx.Deadline()
serverSpanCtx := trace.SpanContextFromContext(ctx.Context)
base := context.WithoutCancel(ctx.Context)
if serverSpanCtx.IsValid() {
base = trace.ContextWithRemoteSpanContext(base, serverSpanCtx)
}
var detachedCtx context.Context
var detachedCancel context.CancelFunc
if hasDeadline {
detachedCtx, detachedCancel = context.WithDeadline(base, deadline)
} else {
// Fallback if no deadline was set (rare, as newContext enforces it).
detachedCtx, detachedCancel = context.WithCancel(base)
}
startOpts := []trace.SpanStartOption{trace.WithSpanKind(trace.SpanKindInternal)}
if serverSpanCtx.IsValid() {
startOpts = append(startOpts, trace.WithLinks(trace.Link{SpanContext: serverSpanCtx}))
}
workerCtx, workerSpan := gotenberg.Tracer().Start(detachedCtx, "webhook.Async", startOpts...)
ctx.Context = workerCtx
originalCancel := cancel
return func() {
workerSpan.End()
detachedCancel()
originalCancel()
}
}

View File

@@ -0,0 +1,84 @@
package webhook
import (
"context"
"testing"
"time"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
)
func TestDetachAsyncContext_PreservesTraceContext(t *testing.T) {
traceID, _ := trace.TraceIDFromHex("0123456789abcdef0123456789abcdef")
spanID, _ := trace.SpanIDFromHex("0123456789abcdef")
sc := trace.NewSpanContext(trace.SpanContextConfig{
TraceID: traceID,
SpanID: spanID,
TraceFlags: trace.FlagsSampled,
Remote: true,
})
reqCtx, reqCancel := context.WithDeadline(
trace.ContextWithSpanContext(context.Background(), sc),
time.Now().Add(2*time.Hour),
)
defer reqCancel()
ctx := &api.Context{Context: reqCtx}
cancel := detachAsyncContext(ctx, func() {})
defer cancel()
got := trace.SpanContextFromContext(ctx.Context)
if got.TraceID() != sc.TraceID() {
t.Errorf("expected the detached context to keep trace id %s, got %s", sc.TraceID(), got.TraceID())
}
}
func TestDetachAsyncContext_PreservesDeadline(t *testing.T) {
deadline := time.Now().Add(2 * time.Hour)
reqCtx, reqCancel := context.WithDeadline(context.Background(), deadline)
defer reqCancel()
ctx := &api.Context{Context: reqCtx}
cancel := detachAsyncContext(ctx, func() {})
defer cancel()
got, ok := ctx.Deadline()
if !ok {
t.Fatal("expected the detached context to keep a deadline")
}
if !got.Equal(deadline) {
t.Errorf("expected deadline %v, got %v", deadline, got)
}
}
func TestDetachAsyncContext_SurvivesRequestCancellation(t *testing.T) {
reqCtx, reqCancel := context.WithDeadline(context.Background(), time.Now().Add(2*time.Hour))
ctx := &api.Context{Context: reqCtx}
cancel := detachAsyncContext(ctx, func() {})
defer cancel()
// Cancelling the inbound request must not abort the detached context.
reqCancel()
if err := ctx.Err(); err != nil {
t.Errorf("expected the detached context to survive request cancellation, got %v", err)
}
}
func TestDetachAsyncContext_CancelInvokesOriginal(t *testing.T) {
reqCtx, reqCancel := context.WithDeadline(context.Background(), time.Now().Add(time.Hour))
defer reqCancel()
called := 0
ctx := &api.Context{Context: reqCtx}
cancel := detachAsyncContext(ctx, func() { called++ })
cancel()
if called != 1 {
t.Errorf("expected the original cancel to be invoked once, got %d", called)
}
}

View File

@@ -224,7 +224,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
startTime: startTime,
client: &retryablehttp.Client{
HTTPClient: gotenberg.NewOutboundHttpClient(w.clientTimeout, w.allowList, w.denyList, ipOpts...),
HTTPClient: gotenberg.NewOutboundHttpClient(w.clientTimeout, w.allowList, w.denyList, w.enableEnvironmentProxy, ipOpts...),
RetryMax: w.maxRetry,
RetryWaitMin: w.retryMinWait,
RetryWaitMax: w.retryMaxWait,
@@ -314,27 +314,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
return c.NoContent(http.StatusNoContent)
}
if deadline, ok := ctx.Deadline(); ok {
// Create a new context derived from Background (detached from Request)
// but with the same deadline as the original context.
detachedCtx, detachedCancel := context.WithDeadline(context.Background(), deadline)
// Replace the embedded context in the api.Context struct.
// The modules downstream will now use this detached context.
ctx.Context = detachedCtx
// We must wrap the cancel function.
// 1. detachedCancel() cleans up our new detached context.
// 2. originalCancel() (captured from c.Get("cancel")) cleans up the working directory.
originalCancel := cancel
cancel = func() {
detachedCancel()
originalCancel()
}
} else {
// Fallback if no deadline was set (rare, as newContext enforces it).
ctx.Context = context.Background()
}
cancel = detachAsyncContext(ctx, cancel)
// As a webhook URL has been given, we handle the request in a
// goroutine and return immediately.

View File

@@ -0,0 +1,86 @@
package webhook
import (
"context"
"testing"
"time"
"go.opentelemetry.io/otel"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
)
// TestDetachAsyncContext_TraceContinuity asserts that an asynchronous webhook
// conversion stays in the inbound request's trace: the worker-root
// webhook.Async span and the downstream conversion span share the server
// span's trace id, and webhook.Async links back to the server span.
func TestDetachAsyncContext_TraceContinuity(t *testing.T) {
recorder := tracetest.NewSpanRecorder()
provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
previous := otel.GetTracerProvider()
otel.SetTracerProvider(provider)
t.Cleanup(func() { otel.SetTracerProvider(previous) })
serverCtx, serverSpan := provider.Tracer("test").Start(
context.Background(),
"POST /forms/chromium/convert/html",
trace.WithSpanKind(trace.SpanKindServer),
)
serverSpanCtx := serverSpan.SpanContext()
reqCtx, reqCancel := context.WithDeadline(serverCtx, time.Now().Add(time.Hour))
defer reqCancel()
ctx := &api.Context{Context: reqCtx}
cancel := detachAsyncContext(ctx, func() {})
// Simulate a downstream conversion span using the detached context, as the
// chromium/libreoffice engines would.
_, conversionSpan := gotenberg.Tracer().Start(ctx.Context, "chromium.Pdf", trace.WithSpanKind(trace.SpanKindClient))
conversionSpan.End()
// The server span ends when the synchronous handler returns, before the
// asynchronous worker finishes.
serverSpan.End()
cancel()
var asyncSpan, conversion sdktrace.ReadOnlySpan
for _, s := range recorder.Ended() {
switch s.Name() {
case "webhook.Async":
asyncSpan = s
case "chromium.Pdf":
conversion = s
}
}
if asyncSpan == nil {
t.Fatal("expected a webhook.Async span to be recorded")
}
if conversion == nil {
t.Fatal("expected a chromium.Pdf span to be recorded")
}
if conversion.SpanContext().TraceID() != serverSpanCtx.TraceID() {
t.Errorf("conversion span trace id = %s, want %s", conversion.SpanContext().TraceID(), serverSpanCtx.TraceID())
}
if asyncSpan.SpanContext().TraceID() != serverSpanCtx.TraceID() {
t.Errorf("webhook.Async trace id = %s, want %s", asyncSpan.SpanContext().TraceID(), serverSpanCtx.TraceID())
}
if conversion.Parent().SpanID() != asyncSpan.SpanContext().SpanID() {
t.Errorf("conversion span parent = %s, want webhook.Async %s", conversion.Parent().SpanID(), asyncSpan.SpanContext().SpanID())
}
links := asyncSpan.Links()
if len(links) != 1 {
t.Fatalf("expected webhook.Async to have 1 link, got %d", len(links))
}
if links[0].SpanContext.SpanID() != serverSpanCtx.SpanID() {
t.Errorf("webhook.Async link span id = %s, want %s", links[0].SpanContext.SpanID(), serverSpanCtx.SpanID())
}
}

View File

@@ -18,19 +18,20 @@ func init() {
// Webhook is a module that provides a middleware for uploading output files
// to any destinations in an asynchronous fashion.
type Webhook struct {
enableSyncMode bool
allowList []*regexp2.Regexp
denyList []*regexp2.Regexp
errorAllowList []*regexp2.Regexp
errorDenyList []*regexp2.Regexp
denyPrivateIPs bool
denyPublicIPs bool
maxRetry int
retryMinWait time.Duration
retryMaxWait time.Duration
clientTimeout time.Duration
asyncCount atomic.Int64
disable bool
enableSyncMode bool
allowList []*regexp2.Regexp
denyList []*regexp2.Regexp
errorAllowList []*regexp2.Regexp
errorDenyList []*regexp2.Regexp
denyPrivateIPs bool
denyPublicIPs bool
enableEnvironmentProxy bool
maxRetry int
retryMinWait time.Duration
retryMaxWait time.Duration
clientTimeout time.Duration
asyncCount atomic.Int64
disable bool
}
// Descriptor returns an [Webhook]'s module descriptor.
@@ -44,6 +45,7 @@ func (w *Webhook) Descriptor() gotenberg.ModuleDescriptor {
fs.StringSlice("webhook-deny-list", []string{}, "Set the denied URLs for the webhook feature using regular expressions - supports multiple values")
fs.Bool("webhook-deny-private-ips", false, "Reject webhook URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted webhook destinations to mitigate SSRF against internal services")
fs.Bool("webhook-deny-public-ips", false, "Reject webhook URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent callbacks from leaving a private network")
fs.Bool("webhook-enable-environment-proxy", false, "Route webhook callbacks through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials")
fs.Int("webhook-max-retry", 4, "Set the maximum number of retries for the webhook feature")
// Deprecated flags.
@@ -78,6 +80,7 @@ func (w *Webhook) Provision(ctx *gotenberg.Context) error {
w.errorDenyList = flags.MustDeprecatedRegexpSlice("webhook-error-deny-list", "webhook-deny-list")
w.denyPrivateIPs = flags.MustBool("webhook-deny-private-ips")
w.denyPublicIPs = flags.MustBool("webhook-deny-public-ips")
w.enableEnvironmentProxy = flags.MustBool("webhook-enable-environment-proxy")
w.maxRetry = flags.MustInt("webhook-max-retry")
w.retryMinWait = flags.MustDuration("webhook-retry-min-wait")
w.retryMaxWait = flags.MustDuration("webhook-retry-max-wait")

View File

@@ -68,9 +68,11 @@ Available tags:
- `the "<name>" PDF should have <N> page(s)`
- `the "<name>" PDF (should|should NOT) be set to landscape orientation`
- `the "<name>" PDF (should|should NOT) have the following content at page <N>:` (docstring)
- `the "<name>" PDF (should|should NOT) have content matching "<regexp>" at page <N>`
- `the (response|webhook request) PDF(s) should be valid "<standard>" with a tolerance of <N> failed rule(s)` (standards: `PDF/A-1b`, `PDF/A-2b`, `PDF/A-3b`, `PDF/UA-1`, `PDF/UA-2`)
- `the (response|webhook request) PDF(s) (should|should NOT) be flatten`
- `the (response|webhook request) PDF(s) (should|should NOT) be encrypted`
- `the (response|webhook request) PDF(s) (should|should NOT) allow "<action>"` (actions: `printing`, `copying`, `modifying`, `annotating`)
- `the (response|webhook request) PDF(s) (should|should NOT) have the "<filename>" file embedded`
- `the "<name>" PDF should have <N> image(s)`
- `the Gotenberg container (should|should NOT) log the following entries:` (table of log substrings)

View File

@@ -548,6 +548,25 @@ Feature: /forms/chromium/convert/html
Error: Exception 2
"""
Scenario: POST /forms/chromium/convert/html (Document Outline)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-outline-html/index.html | file |
| generateDocumentOutline | true | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
# generateTaggedPdf is left unset, yet the outline must still be embedded:
# Gotenberg enables tagged PDF automatically because Chromium derives the
# outline from the structure tree. See issue #1579.
Then the "foo.pdf" PDF should have a document outline
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-outline-html/index.html | file |
| Gotenberg-Output-Filename | bar | header |
Then the response status code should be 200
Then the "bar.pdf" PDF should NOT have a document outline
Scenario: POST /forms/chromium/convert/html (Bad Request)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
@@ -735,7 +754,7 @@ Feature: /forms/chromium/convert/html
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
The requested split mode is not supported, or no PDF engine could process it. Valid modes: 'intervals', 'pages'.
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
@@ -744,7 +763,7 @@ Feature: /forms/chromium/convert/html
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
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.
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |

View File

@@ -652,7 +652,7 @@ Feature: /forms/chromium/convert/markdown
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
The requested split mode is not supported, or no PDF engine could process it. Valid modes: 'intervals', 'pages'.
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
@@ -662,7 +662,7 @@ Feature: /forms/chromium/convert/markdown
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
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.
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |

View File

@@ -815,7 +815,7 @@ Feature: /forms/chromium/convert/url
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
The requested split mode is not supported, or no PDF engine could process it. Valid modes: 'intervals', 'pages'.
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
@@ -825,7 +825,7 @@ Feature: /forms/chromium/convert/url
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
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.
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):

View File

@@ -72,6 +72,15 @@ Feature: /forms/chromium/screenshot/html
Then the response status code should be 200
Then the response header "Content-Type" should be "image/png"
Scenario: POST /forms/chromium/screenshot/html (Device Scale Factor)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| deviceScaleFactor | 1.0 | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "image/png"
Scenario: POST /forms/chromium/screenshot/html (Omit Background)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):

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