Compare commits

...

238 Commits

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

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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-31 09:33:37 +02:00
dependabot[bot]
0a62359691 chore(deps-dev): bump prettier from 3.9.5 to 3.9.6
Bumps [prettier](https://github.com/prettier/prettier) from 3.9.5 to 3.9.6.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.5...3.9.6)

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

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-17 16:44:29 +02:00
dependabot[bot]
8aa5a4f0e7 chore(deps-dev): bump prettier from 3.9.4 to 3.9.5
Bumps [prettier](https://github.com/prettier/prettier) from 3.9.4 to 3.9.5.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.4...3.9.5)

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-13 10:05:55 +02:00
dependabot[bot]
b5f77c3b73 chore(deps-dev): bump prettier from 3.8.4 to 3.9.4
Bumps [prettier](https://github.com/prettier/prettier) from 3.8.4 to 3.9.4.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.4...3.9.4)

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 09:14:56 +02:00
dependabot[bot]
3b43bfbf33 chore(deps-dev): bump prettier from 3.8.3 to 3.8.4
Bumps [prettier](https://github.com/prettier/prettier) from 3.8.3 to 3.8.4.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.8.4)

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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 12:47:34 +02:00
dependabot[bot]
62d98fef79 chore(deps-dev): bump prettier from 3.8.2 to 3.8.3
Bumps [prettier](https://github.com/prettier/prettier) from 3.8.2 to 3.8.3.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.2...3.8.3)

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-14 22:30:29 +02:00
Julien Neuhart
999dd2db40 ci: remove thecodingmachine registry 2026-04-14 20:56:47 +02:00
Julien Neuhart
f44d59d514 fix(Dockefile): pin Chromium to version 146 on ppc64le arch 2026-04-13 20:10:47 +02:00
Julien Neuhart
092b526ab5 docs: more succint [skip ci] 2026-04-13 17:59:14 +02:00
Julien Neuhart
0b5eaff8d9 chore(deps): update Go dependencies 2026-04-13 17:45:25 +02:00
Julien Neuhart
46e190970f fix(exiftool): remove System: prefixes 2026-04-13 17:44:58 +02:00
Julien Neuhart
55d19522a8 fix(exiftool): prevent line breaks 2026-04-11 13:11:47 +02:00
Julien Neuhart
924576d3d4 fix(outboundURLs): better detaults 2026-04-11 13:05:05 +02:00
Julien Neuhart
405d8d1c2b chore(deps-dev): update package-lock.json 2026-04-10 13:43:20 +02:00
Julien Neuhart
1b1e100107 chore(deps): update Go dependencies 2026-04-10 13:42:38 +02:00
Julien Neuhart
c3b4702424 Revert "ci(ppc64le): switch to ubuntu-24.04-ppc64le-p10"
This reverts commit 866ae53436.
2026-04-10 13:36:23 +02:00
Julien Neuhart
866ae53436 ci(ppc64le): switch to ubuntu-24.04-ppc64le-p10 2026-04-09 22:00:57 +02:00
Julien Neuhart
53e6a0ecf8 Revert "test(integration): increase timeout to 90min"
This reverts commit f1d1215c7c.
2026-04-07 21:11:52 +02:00
Julien Neuhart
eab3d2da12 chore(deps): update Go dependencies 2026-04-07 21:11:41 +02:00
Julien Neuhart
3f01ca18d3 fix: better denied list 2026-04-07 21:11:41 +02:00
Julien Neuhart
405f1069c0 fix(exitool): prevent control characters 2026-04-07 21:11:41 +02:00
Julien Neuhart
f1d1215c7c test(integration): increase timeout to 90min 2026-04-07 16:21:56 +02:00
Julien Neuhart
9bf12e7cb9 fix(chromium): set PDFENGINES_CONVERT_ENGINES to empty on chromium only variants 2026-04-06 18:52:29 +02:00
Julien Neuhart
b87a6d4dfb fix(Dockerfile): re-add cURL 2026-04-06 18:46:55 +02:00
Julien Neuhart
a80191a288 docs(README): add PDFBolt as sponsor [ci skip] 2026-04-05 21:40:48 +02:00
Julien Neuhart
e4a43434dc docs: improve godoc and documentation [skip ci] 2026-04-03 14:23:18 +02:00
Julien Neuhart
4811a00543 docs(test/integration/README.md): add missing scenarios 2026-04-02 22:07:40 +02:00
Julien Neuhart
cfb48d9af4 fix(regex): add timeout 2026-04-02 21:58:44 +02:00
Julien Neuhart
8b65315a09 fix(Dockerfile): reposition COPY intructions to prevent cache invalidation 2026-03-31 19:01:13 +02:00
Julien Neuhart
20522fd1ac fix(exiftool): exclude more system tags, but keep some derived tags (safe ones) 2026-03-31 19:00:49 +02:00
Julien Neuhart
e28e2ebf0f chore(supervisor): InfoContext to DebugContext 2026-03-31 15:12:26 +02:00
Julien Neuhart
15050a311b fix(exiftool): case-insensitive comparison and expanded blocklist 2026-03-31 14:18:44 +02:00
Julien Neuhart
c99eb471f9 docs(SECURITY.md): prefer GitHub advisories [skip ci] 2026-03-31 14:05:55 +02:00
Julien Neuhart
53c2f97008 docs(markdown): human-centric instead of agents first [skip ci] 2026-03-31 12:44:13 +02:00
Julien Neuhart
61cadd0e6a chore(Dockerfile): better distinction between intructions in a single RUN 2026-03-30 22:24:32 +02:00
Julien Neuhart
813926cd47 chore(Dockerfile): typo 2026-03-30 21:49:14 +02:00
Julien Neuhart
f447e31ae2 fix(Dockerfile): re-add fonts-noto-core to prevent tofu on most languages 2026-03-30 19:12:33 +02:00
Julien Neuhart
00c6ed0387 fix(Dockerfile): use new LOG_STD_ENABLE_GCP_FIELDS instead of deprecated flag 2026-03-30 19:10:06 +02:00
Julien Neuhart
cdf861bcfd fix(Makefile): remove prometheus deprecated flag 2026-03-30 19:08:37 +02:00
Julien Neuhart
5279d73928 chore(testdata): bigger image.png in html-with-asset 2026-03-30 19:07:39 +02:00
Julien Neuhart
e6d2a5640b ci: handle new variants 2026-03-30 18:14:15 +02:00
Julien Neuhart
4387a4f122 chore(Dockerfile): update container description 2026-03-30 18:14:15 +02:00
Julien Neuhart
bf0d0a4f40 feat(main): add dedicated Go entrypoints for libreoffice / chromium only variants 2026-03-30 18:14:15 +02:00
Julien Neuhart
f8809b3943 feat(Dockerfile): no dedicated Dockerfiles for AWS and Cloud Run + chromium/libreoffice only variants 2026-03-30 18:14:15 +02:00
Julien Neuhart
9e5b05ad3c feat(Dockerfile): libreoffice and chromium only variants 2026-03-30 18:14:15 +02:00
Julien Neuhart
452a500955 chore(webhook): deprecate Gotenberg-Webhook-Error-Url, make it non-mandatory if Gotenberg-Webhook-Events-Url is set 2026-03-30 18:14:15 +02:00
Julien Neuhart
1ab0117943 fix(chromium): assets not loading 2026-03-30 18:14:15 +02:00
Julien Neuhart
5be574081c ci: bump checkout actions 2026-03-28 21:45:11 +01:00
Julien Neuhart
6430bc6a5c test(integration): add retry mecanism 2026-03-28 21:09:22 +01:00
Julien Neuhart
e0eff40f74 tests(integreation): fix failing scenarios and add screenshots one 2026-03-28 20:39:53 +01:00
Julien Neuhart
385cbe6590 feat(webhook): add events 2026-03-28 19:00:07 +01:00
Julien Neuhart
043b1588de fix(exiftool): arbitrary file write 2026-03-28 14:19:04 +01:00
Julien Neuhart
e5d861def3 refactor(otel): disable telemetry by default for system routes 2026-03-28 14:08:50 +01:00
Julien Neuhart
72bc6f895a feat(otel): flag to disable telemetry for system routes 2026-03-28 14:06:16 +01:00
Julien Neuhart
7549a69f71 feat(otel): switch from software name to binary path 2026-03-28 13:31:28 +01:00
Julien Neuhart
ebf0548d19 refactor: switch from multierr to errors.join 2026-03-27 22:14:01 +01:00
Julien Neuhart
d3a65a587c feat(supervisor): optional idle shutdown 2026-03-27 21:16:49 +01:00
Julien Neuhart
8f7c1c98ad fix(api): handle correctly filename that takes more that 200 bytes 2026-03-27 20:55:50 +01:00
Julien Neuhart
40d75882a2 fix(chromium): better singlePage handling - accounting for margins now 2026-03-27 19:50:20 +01:00
Julien Neuhart
bb91259829 feat(libreoffice): add viewer preference form fields 2026-03-27 19:40:42 +01:00
Julien Neuhart
0b33be17a4 fix(chromium): waitForSelector and waitForExpression poll indefinitely when the main page returns a bad HTTP status (e.g., 500) 2026-03-27 17:32:27 +01:00
Julien Neuhart
3a78b89c97 docs(otel): add pointers 2026-03-27 16:56:29 +01:00
Julien Neuhart
c72be765b0 feat(otel): add more tracing when communicating with internal tools / external APIs 2026-03-27 16:50:24 +01:00
Julien Neuhart
4e9f63004d feat(otel): add OpenTelemetry support 2026-03-27 16:28:45 +01:00
Julien Neuhart
08088c15f4 feat(chromium): skip or waits for networkIdle2 event 2026-03-27 11:01:39 +01:00
Julien Neuhart
8625a4e899 fix(webhook/downloadFrom): better default security and DX for allow / deny lists 2026-03-27 10:52:04 +01:00
Julien Neuhart
06b2b2e10c fix(chromium): better default security and DX for allow / deny lists 2026-03-27 09:47:21 +01:00
Julien Neuhart
bd6d92be9b fix(supervisor): queue slot - second request hitting a busy node now gets a 429 immediately, pushing backpressure to a load balancer, if any 2026-03-26 21:24:51 +01:00
Julien Neuhart
ed22f1e5e6 refactor(supervisor): better orchestration of processes for conversion requests 2026-03-26 21:24:51 +01:00
Julien Neuhart
8e3acc8d0a docs(agents): better (?) organization of context files 2026-03-26 21:24:51 +01:00
dependabot[bot]
51afa5ba85 chore(deps): bump github.com/chromedp/chromedp from 0.14.2 to 0.15.0
Bumps [github.com/chromedp/chromedp](https://github.com/chromedp/chromedp) from 0.14.2 to 0.15.0.
- [Release notes](https://github.com/chromedp/chromedp/releases)
- [Commits](https://github.com/chromedp/chromedp/compare/v0.14.2...v0.15.0)

---
updated-dependencies:
- dependency-name: github.com/chromedp/chromedp
  dependency-version: 0.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-23 10:20:37 +01:00
Julien Neuhart
d67500ee05 chore(cmd): new start title 2026-03-21 21:17:35 +01:00
Julien Neuhart
0bec14c6ac docs: better README.md 2026-03-21 20:42:05 +01:00
Julien Neuhart
3b0eb06991 fix(agents): better context 2026-03-19 20:49:06 +01:00
Julien Neuhart
410c1dfd7f fix(pdfengines): single file for watermark/stamp, and add the feature to the downloadFrom one 2026-03-18 23:22:31 +01:00
Julien Neuhart
b98378311f feat(agents): add bruno section 2026-03-18 23:14:00 +01:00
Julien Neuhart
e6306e7c2f feat(bruno): add collection 2026-03-18 22:36:58 +01:00
Julien Neuhart
0663e5f92b feat(pdfengines): add rotate feature 2026-03-18 22:36:20 +01:00
Julien Neuhart
21e300fcec fix(pdfengines): better workflow when applying PDF/A or PDF/UA compliance 2026-03-18 15:24:31 +01:00
Julien Neuhart
7fb4c89832 chore(agents): better entrypoints 2026-03-18 13:57:56 +01:00
Julien Neuhart
1e26fdd35b fix(libreoffice): set EmbedStandardFonts to true when applying PDF/A or PDF/UA compliance 2026-03-18 13:45:01 +01:00
Julien Neuhart
19db80bc2e feat(pdfengines): add watermark and stamp feature 2026-03-18 04:46:12 +01:00
Julien Neuhart
4ac493250c fix(agents): update persona file when adding a new testing scenario [skip ci] 2026-03-17 19:27:30 +01:00
Julien Neuhart
ec9d960538 feat(agents): better personas 2026-03-17 19:24:11 +01:00
Julien Neuhart
5fa6603fa4 chore(deps): update Go dependencies 2026-03-17 18:32:46 +01:00
dependabot[bot]
d81517bf26 chore(deps): bump golang.org/x/net from 0.51.0 to 0.52.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.51.0 to 0.52.0.
- [Commits](https://github.com/golang/net/compare/v0.51.0...v0.52.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-16 10:26:35 +01:00
dependabot[bot]
9af0f2896c chore(deps): bump github.com/testcontainers/testcontainers-go
Bumps [github.com/testcontainers/testcontainers-go](https://github.com/testcontainers/testcontainers-go) from 0.40.0 to 0.41.0.
- [Release notes](https://github.com/testcontainers/testcontainers-go/releases)
- [Commits](https://github.com/testcontainers/testcontainers-go/compare/v0.40.0...v0.41.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-16 10:26:26 +01:00
dependabot[bot]
f619ce09d6 chore(deps): bump golang.org/x/sync from 0.19.0 to 0.20.0
Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.19.0 to 0.20.0.
- [Commits](https://github.com/golang/sync/compare/v0.19.0...v0.20.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-13 09:40:11 +01:00
dependabot[bot]
58c41dd01f chore(deps): bump golang.org/x/net from 0.50.0 to 0.51.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.50.0 to 0.51.0.
- [Commits](https://github.com/golang/net/compare/v0.50.0...v0.51.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-13 09:37:57 +01:00
dependabot[bot]
ec3e415bf0 chore(deps): bump github.com/shirou/gopsutil/v4 from 4.26.1 to 4.26.2
Bumps [github.com/shirou/gopsutil/v4](https://github.com/shirou/gopsutil) from 4.26.1 to 4.26.2.
- [Release notes](https://github.com/shirou/gopsutil/releases)
- [Commits](https://github.com/shirou/gopsutil/compare/v4.26.1...v4.26.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-13 09:37:50 +01:00
Romain Prévost
230cb8da2c fix(security): avoid shell interpolation 2026-03-13 09:37:33 +01:00
Julien Neuhart
59f96358c4 feat(pdfengines): new form field autoIndexBookmarks for the merge route 2026-03-13 09:36:47 +01:00
Julien Neuhart
874e78c6cd fix(pdfengines): correctly update the indexes if the bookmarks form field (map format) is given 2026-03-13 09:36:47 +01:00
Julien Neuhart
caea81501d test(integration): add bookmarks routes 2026-03-13 09:36:47 +01:00
Julien Neuhart
1578253fb1 feat(pdfengines): add read bookmarks pdfcpu implementation 2026-03-13 09:36:47 +01:00
Julien Neuhart
fec6437c5d feat(pdfengines): add bookmarks read route 2026-03-13 09:36:47 +01:00
Julien Neuhart
edff24913a feat(pdfengines): add bookmarks write pdfcpu implementation 2026-03-13 09:36:47 +01:00
Julien Neuhart
5120b49639 feat(pdfengines): add bookmarks write route and update the merge route with this feature 2026-03-13 09:36:47 +01:00
Julien Neuhart
0e13cc3ed8 docs(README): typo in sponsors [skip ci] 2026-02-24 21:36:53 +01:00
dependabot[bot]
cb49e8e4d1 chore(deps): bump github.com/labstack/echo/v4 from 4.15.0 to 4.15.1 (#1476)
Bumps [github.com/labstack/echo/v4](https://github.com/labstack/echo) from 4.15.0 to 4.15.1.
- [Release notes](https://github.com/labstack/echo/releases)
- [Changelog](https://github.com/labstack/echo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/labstack/echo/compare/v4.15.0...v4.15.1)

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

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

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

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

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

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

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

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

* feat: add chromium-max-concurrency flag

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

* test: add integration tests for concurrent Chromium conversions

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

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

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

* fixes cs

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

View File

@@ -0,0 +1,86 @@
meta {
name: HTML to PDF
type: http
seq: 2
}
post {
url: {{baseUrl}}/forms/chromium/convert/html
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../test/integration/testdata/page-1-html/index.html)
~landscape: false
~printBackground: false
~scale: 1.0
~singlePage: false
~paperWidth: 8.5
~paperHeight: 11
~marginTop: 0.39
~marginBottom: 0.39
~marginLeft: 0.39
~marginRight: 0.39
~nativePageRanges: 1-2
~preferCssPageSize: false
~generateDocumentOutline: false
~generateTaggedPdf: false
~skipNetworkIdleEvent: false
~failOnHttpStatusCodes: [499,599]
~failOnResourceHttpStatusCodes: []
~ignoreResourceHttpStatusDomains: []
~failOnResourceLoadingFailed: false
~failOnConsoleExceptions: false
~waitDelay: 0s
~waitWindowStatus:
~waitForExpression:
~waitForSelector:
~cookies: [{"name":"my_cookie","value":"my_value","domain":"example.com"}]
~userAgent:
~extraHttpHeaders: {"X-Custom-Header":"value"}
~emulatedMediaType: print
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
~omitBackground: false
~splitMode: intervals
~splitSpan: 1
~splitUnify: false
~pdfa: PDF/A-1b
~pdfua: true
~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:
~watermarkOptions: {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"}
~stampSource: text
~stampExpression: DRAFT
~stampPages:
~stampOptions: {"scale":"0.5 abs","rot":"45"}
~rotateAngle: 90
~rotatePages:
}
headers {
~Gotenberg-Output-Filename: my-file
~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

@@ -0,0 +1,87 @@
meta {
name: Markdown to PDF
type: http
seq: 3
}
post {
url: {{baseUrl}}/forms/chromium/convert/markdown
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page-1-markdown/index.html)
files: @file(../../test/integration/testdata/page-1-markdown/page_1.md)
~landscape: false
~printBackground: false
~scale: 1.0
~singlePage: false
~paperWidth: 8.5
~paperHeight: 11
~marginTop: 0.39
~marginBottom: 0.39
~marginLeft: 0.39
~marginRight: 0.39
~nativePageRanges: 1-2
~preferCssPageSize: false
~generateDocumentOutline: false
~generateTaggedPdf: false
~skipNetworkIdleEvent: false
~failOnHttpStatusCodes: [499,599]
~failOnResourceHttpStatusCodes: []
~ignoreResourceHttpStatusDomains: []
~failOnResourceLoadingFailed: false
~failOnConsoleExceptions: false
~waitDelay: 0s
~waitWindowStatus:
~waitForExpression:
~waitForSelector:
~cookies: [{"name":"my_cookie","value":"my_value","domain":"example.com"}]
~userAgent:
~extraHttpHeaders: {"X-Custom-Header":"value"}
~emulatedMediaType: print
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
~omitBackground: false
~splitMode: intervals
~splitSpan: 1
~splitUnify: false
~pdfa: PDF/A-1b
~pdfua: true
~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:
~watermarkOptions: {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"}
~stampSource: text
~stampExpression: DRAFT
~stampPages:
~stampOptions: {"scale":"0.5 abs","rot":"45"}
~rotateAngle: 90
~rotatePages:
}
headers {
~Gotenberg-Output-Filename: my-file
~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

@@ -0,0 +1,86 @@
meta {
name: URL to PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/chromium/convert/url
body: multipartForm
auth: none
}
body:multipart-form {
url: https://example.com
~landscape: false
~printBackground: false
~scale: 1.0
~singlePage: false
~paperWidth: 8.5
~paperHeight: 11
~marginTop: 0.39
~marginBottom: 0.39
~marginLeft: 0.39
~marginRight: 0.39
~nativePageRanges: 1-2
~preferCssPageSize: false
~generateDocumentOutline: false
~generateTaggedPdf: false
~skipNetworkIdleEvent: false
~failOnHttpStatusCodes: [499,599]
~failOnResourceHttpStatusCodes: []
~ignoreResourceHttpStatusDomains: []
~failOnResourceLoadingFailed: false
~failOnConsoleExceptions: false
~waitDelay: 0s
~waitWindowStatus:
~waitForExpression:
~waitForSelector:
~cookies: [{"name":"my_cookie","value":"my_value","domain":"example.com"}]
~userAgent:
~extraHttpHeaders: {"X-Custom-Header":"value"}
~emulatedMediaType: print
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
~omitBackground: false
~splitMode: intervals
~splitSpan: 1
~splitUnify: false
~pdfa: PDF/A-1b
~pdfua: true
~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:
~watermarkOptions: {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"}
~stampSource: text
~stampExpression: DRAFT
~stampPages:
~stampOptions: {"scale":"0.5 abs","rot":"45"}
~rotateAngle: 90
~rotatePages:
}
headers {
~Gotenberg-Output-Filename: my-file
~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

@@ -0,0 +1,48 @@
meta {
name: HTML Screenshot
type: http
seq: 2
}
post {
url: {{baseUrl}}/forms/chromium/screenshot/html
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page-1-html/index.html)
~width: 800
~height: 600
~clip: false
~format: png
~quality: 100
~optimizeForSpeed: false
~skipNetworkIdleEvent: false
~failOnHttpStatusCodes: [499,599]
~failOnResourceHttpStatusCodes: []
~ignoreResourceHttpStatusDomains: []
~failOnResourceLoadingFailed: false
~failOnConsoleExceptions: false
~waitDelay: 0s
~waitWindowStatus:
~waitForExpression:
~waitForSelector:
~cookies: [{"name":"my_cookie","value":"my_value","domain":"example.com"}]
~userAgent:
~extraHttpHeaders: {"X-Custom-Header":"value"}
~emulatedMediaType: screen
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
~omitBackground: false
~deviceScaleFactor: 1.0
}
headers {
~Gotenberg-Output-Filename: my-screenshot
~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

@@ -0,0 +1,49 @@
meta {
name: Markdown Screenshot
type: http
seq: 3
}
post {
url: {{baseUrl}}/forms/chromium/screenshot/markdown
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page-1-markdown/index.html)
files: @file(../../test/integration/testdata/page-1-markdown/page_1.md)
~width: 800
~height: 600
~clip: false
~format: png
~quality: 100
~optimizeForSpeed: false
~skipNetworkIdleEvent: false
~failOnHttpStatusCodes: [499,599]
~failOnResourceHttpStatusCodes: []
~ignoreResourceHttpStatusDomains: []
~failOnResourceLoadingFailed: false
~failOnConsoleExceptions: false
~waitDelay: 0s
~waitWindowStatus:
~waitForExpression:
~waitForSelector:
~cookies: [{"name":"my_cookie","value":"my_value","domain":"example.com"}]
~userAgent:
~extraHttpHeaders: {"X-Custom-Header":"value"}
~emulatedMediaType: screen
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
~omitBackground: false
~deviceScaleFactor: 1.0
}
headers {
~Gotenberg-Output-Filename: my-screenshot
~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

@@ -0,0 +1,48 @@
meta {
name: URL Screenshot
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/chromium/screenshot/url
body: multipartForm
auth: none
}
body:multipart-form {
url: https://example.com
~width: 800
~height: 600
~clip: false
~format: png
~quality: 100
~optimizeForSpeed: false
~skipNetworkIdleEvent: false
~failOnHttpStatusCodes: [499,599]
~failOnResourceHttpStatusCodes: []
~ignoreResourceHttpStatusDomains: []
~failOnResourceLoadingFailed: false
~failOnConsoleExceptions: false
~waitDelay: 0s
~waitWindowStatus:
~waitForExpression:
~waitForSelector:
~cookies: [{"name":"my_cookie","value":"my_value","domain":"example.com"}]
~userAgent:
~extraHttpHeaders: {"X-Custom-Header":"value"}
~emulatedMediaType: screen
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
~omitBackground: false
~deviceScaleFactor: 1.0
}
headers {
~Gotenberg-Output-Filename: my-screenshot
~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

@@ -0,0 +1,11 @@
meta {
name: Debug
type: http
seq: 3
}
get {
url: {{baseUrl}}/debug
body: none
auth: none
}

View File

@@ -0,0 +1,11 @@
meta {
name: Health
type: http
seq: 1
}
get {
url: {{baseUrl}}/health
body: none
auth: none
}

View File

@@ -0,0 +1,11 @@
meta {
name: Prometheus Metrics
type: http
seq: 4
}
get {
url: {{baseUrl}}/prometheus/metrics
body: none
auth: none
}

View File

@@ -0,0 +1,11 @@
meta {
name: Version
type: http
seq: 2
}
get {
url: {{baseUrl}}/version
body: none
auth: none
}

View File

@@ -0,0 +1,103 @@
meta {
name: Convert to PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/libreoffice/convert
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../test/integration/testdata/page_1.docx)
~password:
~landscape: false
~nativePageRanges:
~updateIndexes: false
~exportFormFields: true
~allowDuplicateFieldNames: false
~exportBookmarks: true
~exportBookmarksToPdfDestination: false
~exportPlaceholders: false
~exportNotes: false
~exportNotesPages: false
~exportOnlyNotesPages: false
~exportNotesInMargin: false
~convertOooTargetToPdfTarget: false
~exportLinksRelativeFsys: false
~exportHiddenSlides: false
~skipEmptyPages: false
~addOriginalDocumentAsStream: false
~singlePageSheets: false
~initialView: 0
~initialPage: 1
~magnification: 0
~zoom: 100
~pageLayout: 0
~firstPageOnLeft: false
~resizeWindowToInitialPage: false
~centerWindow: false
~openInFullScreenMode: false
~displayPDFDocumentTitle: true
~hideViewerMenubar: false
~hideViewerToolbar: false
~hideViewerWindowControls: false
~useTransitionEffects: true
~openBookmarkLevels: -1
~losslessImageCompression: false
~quality: 90
~reduceImageResolution: false
~maxImageResolution: 300
~nativeWatermarkText:
~nativeWatermarkColor: 0
~nativeWatermarkFontHeight: 0
~nativeWatermarkRotateAngle: 0
~nativeWatermarkFontName:
~nativeTiledWatermarkText:
~nativePdfFormats: true
~merge: false
~flatten: false
~splitMode: intervals
~splitSpan: 1
~splitUnify: false
~pdfa: PDF/A-1b
~pdfua: true
~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:
~watermarkOptions: {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"}
~stampSource: text
~stampExpression: DRAFT
~stampPages:
~stampOptions: {"scale":"0.5 abs","rot":"45"}
~rotateAngle: 90
~rotatePages:
}
headers {
~Gotenberg-Output-Filename: my-file
~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

@@ -0,0 +1,24 @@
meta {
name: Read Bookmarks
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/bookmarks/read
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
}
headers {
~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

@@ -0,0 +1,26 @@
meta {
name: Write Bookmarks
type: http
seq: 2
}
post {
url: {{baseUrl}}/forms/pdfengines/bookmarks/write
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
bookmarks: [{"title":"Chapter 1","page":1,"children":[{"title":"Section 1.1","page":1}]}]
}
headers {
~Gotenberg-Output-Filename: with-bookmarks
~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

@@ -0,0 +1,27 @@
meta {
name: Convert PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/convert
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
pdfa: PDF/A-1b
~pdfua: true
}
headers {
~Gotenberg-Output-Filename: converted
~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

@@ -0,0 +1,33 @@
meta {
name: Embed Files
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/embed
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../test/integration/testdata/page_1.pdf)
embeds: @file(../test/integration/testdata/embed_1.xml)
embeds: @file(../test/integration/testdata/embed_2.xml)
embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
~facturxXml: @file(../test/integration/testdata/embed_1.xml)
~facturxConformanceLevel: EN 16931
~facturxDocumentType: INVOICE
~facturxVersion: 1.0
~downloadFrom: [{"url":"https://example.com/attachment.xml","embedded":true}]
}
headers {
~Gotenberg-Output-Filename: with-embeds
~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

@@ -0,0 +1,33 @@
meta {
name: Encrypt PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/encrypt
body: multipartForm
auth: none
}
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 {
~Gotenberg-Output-Filename: encrypted
~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

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

@@ -0,0 +1,25 @@
meta {
name: Flatten PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/flatten
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
}
headers {
~Gotenberg-Output-Filename: flattened
~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

@@ -0,0 +1,54 @@
meta {
name: Merge PDFs
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/merge
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
files: @file(../../test/integration/testdata/page_2.pdf)
~flatten: false
~autoIndexBookmarks: false
~pdfa: PDF/A-1b
~pdfua: true
~metadata: {"Author":"Bruno","Title":"Test"}
~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:
~watermarkOptions: {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"}
~stampSource: text
~stampExpression: DRAFT
~stampPages:
~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 {
~Gotenberg-Output-Filename: merged
~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

@@ -0,0 +1,24 @@
meta {
name: Read Metadata
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/metadata/read
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
}
headers {
~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

@@ -0,0 +1,26 @@
meta {
name: Write Metadata
type: http
seq: 2
}
post {
url: {{baseUrl}}/forms/pdfengines/metadata/write
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
metadata: {"Author":"Bruno","Copyright":"Bruno","Creator":"Gotenberg","Keywords":["test"],"Marked":true,"Producer":"Gotenberg","Subject":"Test","Title":"Test Document","Trapped":"Unknown"}
}
headers {
~Gotenberg-Output-Filename: with-metadata
~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

@@ -0,0 +1,27 @@
meta {
name: Rotate PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/rotate
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
rotateAngle: 90
~rotatePages: 1-2
}
headers {
~Gotenberg-Output-Filename: rotated
~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

@@ -0,0 +1,54 @@
meta {
name: Split PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/split
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/pages_3.pdf)
splitMode: intervals
splitSpan: 1
~splitUnify: false
~flatten: false
~pdfa: PDF/A-1b
~pdfua: true
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:
~allowPrinting: false
~allowCopying: false
~allowModifying: false
~allowAnnotating: false
~allowFillingForms: false
~allowAssembling: false
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:
~watermarkOptions: {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"}
~stampSource: text
~stampExpression: DRAFT
~stampPages:
~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 {
~Gotenberg-Output-Filename: split
~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

@@ -0,0 +1,31 @@
meta {
name: Stamp PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/stamp
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
stampSource: text
stampExpression: APPROVED
~stampPages: 1-2
~stampOptions: {"font":"Helvetica","fontSize":"48","color":"#00FF00","opacity":"0.5","rotation":"0"}
~stamp: @file(../../test/integration/testdata/watermark.png)
~downloadFrom: [{"url":"https://example.com/stamp.png","field":"stamp"}]
}
headers {
~Gotenberg-Output-Filename: stamped
~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

@@ -0,0 +1,31 @@
meta {
name: Watermark PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/watermark
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
watermarkSource: text
watermarkExpression: CONFIDENTIAL
~watermarkPages: 1-2
~watermarkOptions: {"font":"Helvetica","fontSize":"48","color":"#FF0000","opacity":"0.3","rotation":"45"}
~watermark: @file(../../test/integration/testdata/watermark.png)
~downloadFrom: [{"url":"https://example.com/watermark.png","field":"watermark"}]
}
headers {
~Gotenberg-Output-Filename: watermarked
~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"}
}

65
.bruno/README.md Normal file
View File

@@ -0,0 +1,65 @@
# Bruno API Collection
[Bruno](https://www.usebruno.com/) collection mirroring every Gotenberg route. Update the collection when adding or modifying a route.
## Structure
```
.bruno/
├── bruno.json # Collection config
├── collection.bru # Collection-level defaults (Gotenberg-Trace header)
├── environments/
│ ├── Local.bru # baseUrl: http://localhost:3000
│ └── Demo.bru # baseUrl: https://demo.gotenberg.dev
├── Health & Info/ # GET routes
├── Chromium/Convert/ # POST routes grouped by module
├── Chromium/Screenshot/
├── LibreOffice/
└── PDF Engines/<Feature>/ # One folder per feature (Merge, Split, Rotate, ...)
```
## `.bru` file format
```bru
meta {
name: <Human-readable name>
type: http
seq: <order within folder>
}
post {
url: {{baseUrl}}/forms/<path>
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/<file>)
<mandatoryField>: <value>
~<optionalField>: <value>
}
headers {
~Gotenberg-Output-Filename: <name>
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}
```
## Conventions
- Mandatory fields have no prefix. Optional fields use `~` (disabled by default in Bruno).
- File references use relative paths to `test/integration/testdata/`.
- Webhook and output filename headers appear on every POST route as optional (`~`).
- One `.bru` file per request. For routes with read/write variants (e.g., bookmarks, metadata), create separate files in the same folder.
## Checklist
1. Create or update the `.bru` file in the matching folder under `.bruno/`.
2. Include all form fields from the route handler. Check `FormData*` calls in the route function.
3. For file upload fields (`files`, `watermark`, `stamp`, `embeds`), use `@file(...)` with a suitable test file.
4. Verify the URL path matches the route's `Path` field exactly.
5. For new module folders, keep the naming consistent (e.g., `PDF Engines/Rotate/`).

6
.bruno/bruno.json Normal file
View File

@@ -0,0 +1,6 @@
{
"version": "1",
"name": "Gotenberg",
"type": "collection",
"ignore": ["node_modules", ".git"]
}

3
.bruno/collection.bru Normal file
View File

@@ -0,0 +1,3 @@
headers {
Gotenberg-Trace: {{traceId}}
}

View File

@@ -0,0 +1,4 @@
vars {
baseUrl: https://demo.gotenberg.dev
traceId: bruno-demo
}

View File

@@ -0,0 +1,4 @@
vars {
baseUrl: http://localhost:3000
traceId: bruno-local
}

3
.env
View File

@@ -2,6 +2,5 @@ GOTENBERG_VERSION=snapshot
DOCKER_REGISTRY=gotenberg DOCKER_REGISTRY=gotenberg
DOCKER_REPOSITORY=gotenberg DOCKER_REPOSITORY=gotenberg
DOCKERFILE=build/Dockerfile DOCKERFILE=build/Dockerfile
DOCKERFILE_CLOUDRUN=build/Dockerfile.cloudrun
DOCKERFILE_AWS_LAMBDA=build/Dockerfile.aws-lambda
DOCKER_BUILD_CONTEXT='.' DOCKER_BUILD_CONTEXT='.'
TARGET=gotenberg

View File

@@ -29,14 +29,32 @@ inputs:
outputs: outputs:
tags: tags:
description: Comma separated list of tag description: Comma separated list of full variant tags
value: ${{ steps.build.outputs.tags }} value: ${{ steps.build.outputs.tags }}
tags_chromium:
description: Comma separated list of Chromium variant tags
value: ${{ steps.build.outputs.tags_chromium }}
tags_libreoffice:
description: Comma separated list of LibreOffice variant tags
value: ${{ steps.build.outputs.tags_libreoffice }}
tags_cloud_run: tags_cloud_run:
description: Comma separated list of Cloud Run tags (linux/amd64 only) description: Comma separated list of Cloud Run full variant tags (linux/amd64 only)
value: ${{ steps.build.outputs.tags_cloud_run }} value: ${{ steps.build.outputs.tags_cloud_run }}
tags_cloud_run_chromium:
description: Comma separated list of Cloud Run Chromium variant tags (linux/amd64 only)
value: ${{ steps.build.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice:
description: Comma separated list of Cloud Run LibreOffice variant tags (linux/amd64 only)
value: ${{ steps.build.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: tags_aws_lambda:
description: Comma separated list of AWS Lambda tags (linux/amd64 and linux/arm64 only) description: Comma separated list of AWS Lambda full variant tags (linux/amd64 and linux/arm64 only)
value: ${{ steps.build.outputs.tags_aws_lambda }} value: ${{ steps.build.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium:
description: Comma separated list of AWS Lambda Chromium variant tags (linux/amd64 and linux/arm64 only)
value: ${{ steps.build.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice:
description: Comma separated list of AWS Lambda LibreOffice variant tags (linux/amd64 and linux/arm64 only)
value: ${{ steps.build.outputs.tags_aws_lambda_libreoffice }}
runs: runs:
using: composite using: composite
@@ -48,7 +66,7 @@ runs:
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Check out code - name: Check out code
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Log in to Docker Hub - name: Log in to Docker Hub
if: inputs.docker_hub_username != '' if: inputs.docker_hub_username != ''
@@ -60,34 +78,53 @@ runs:
- name: Build ${{ inputs.platform }} - name: Build ${{ inputs.platform }}
id: build id: build
shell: bash shell: bash
env:
INPUT_VERSION: ${{ inputs.version }}
INPUT_PLATFORM: ${{ inputs.platform }}
INPUT_ALTERNATE_REPOSITORY: ${{ inputs.alternate_repository }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
run: | run: |
.github/actions/build-test-push/build.sh \ .github/actions/build-test-push/build.sh \
--version "${{ inputs.version }}" \ --version "$INPUT_VERSION" \
--platform "${{ inputs.platform }}" \ --platform "$INPUT_PLATFORM" \
--alternate-repository "${{ inputs.alternate_repository }}" \ --alternate-repository "$INPUT_ALTERNATE_REPOSITORY" \
--dry-run "${{ inputs.dry_run }}" --dry-run "$INPUT_DRY_RUN"
- name: Run integration tests - name: Run integration tests
if: inputs.skip_integrations_tests != 'true' if: inputs.skip_integrations_tests != 'true'
shell: bash shell: bash
env:
INPUT_VERSION: ${{ inputs.version }}
INPUT_PLATFORM: ${{ inputs.platform }}
INPUT_ALTERNATE_REPOSITORY: ${{ inputs.alternate_repository }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
run: | run: |
.github/actions/build-test-push/test.sh \ .github/actions/build-test-push/test.sh \
--version "${{ inputs.version }}" \ --version "$INPUT_VERSION" \
--platform "${{ inputs.platform }}" \ --platform "$INPUT_PLATFORM" \
--alternate-repository "${{ inputs.alternate_repository }}" \ --alternate-repository "$INPUT_ALTERNATE_REPOSITORY" \
--dry-run "${{ inputs.dry_run }}" --dry-run "$INPUT_DRY_RUN"
- name: Push - name: Push
if: inputs.docker_hub_username != '' if: inputs.docker_hub_username != ''
shell: bash shell: bash
env:
INPUT_TAGS: ${{ steps.build.outputs.tags }},${{ steps.build.outputs.tags_chromium }},${{ steps.build.outputs.tags_libreoffice }},${{ steps.build.outputs.tags_cloud_run }},${{ steps.build.outputs.tags_cloud_run_chromium }},${{ steps.build.outputs.tags_cloud_run_libreoffice }},${{ steps.build.outputs.tags_aws_lambda }},${{ steps.build.outputs.tags_aws_lambda_chromium }},${{ steps.build.outputs.tags_aws_lambda_libreoffice }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
run: | run: |
.github/actions/build-test-push/push.sh \ .github/actions/build-test-push/push.sh \
--tags "${{ steps.build.outputs.tags }},${{ steps.build.outputs.tags_cloud_run }},${{ steps.build.outputs.tags_aws_lambda }}" \ --tags "$INPUT_TAGS" \
--dry-run "${{ inputs.dry_run }}" --dry-run "$INPUT_DRY_RUN"
- name: Outputs - name: Outputs
shell: bash shell: bash
run: | run: |
echo "tags=${{ steps.build.outputs.tags }}" echo "tags=${{ steps.build.outputs.tags }}"
echo "tags_chromium=${{ steps.build.outputs.tags_chromium }}"
echo "tags_libreoffice=${{ steps.build.outputs.tags_libreoffice }}"
echo "tags_cloud_run=${{ steps.build.outputs.tags_cloud_run }}" echo "tags_cloud_run=${{ steps.build.outputs.tags_cloud_run }}"
echo "tags_cloud_run_chromium=${{ steps.build.outputs.tags_cloud_run_chromium }}"
echo "tags_cloud_run_libreoffice=${{ steps.build.outputs.tags_cloud_run_libreoffice }}"
echo "tags_aws_lambda=${{ steps.build.outputs.tags_aws_lambda }}" echo "tags_aws_lambda=${{ steps.build.outputs.tags_aws_lambda }}"
echo "tags_aws_lambda_chromium=${{ steps.build.outputs.tags_aws_lambda_chromium }}"
echo "tags_aws_lambda_libreoffice=${{ steps.build.outputs.tags_aws_lambda_libreoffice }}"

View File

@@ -55,8 +55,14 @@ fi
# Build tags arrays. # Build tags arrays.
tags=() tags=()
tags_chromium=()
tags_libreoffice=()
tags_cloud_run=() tags_cloud_run=()
tags_cloud_run_chromium=()
tags_cloud_run_libreoffice=()
tags_aws_lambda=() tags_aws_lambda=()
tags_aws_lambda_chromium=()
tags_aws_lambda_libreoffice=()
IFS='/' read -ra arch <<< "$platform" IFS='/' read -ra arch <<< "$platform"
IFS='.' read -ra semver <<< "$version" IFS='.' read -ra semver <<< "$version"
@@ -69,55 +75,95 @@ if [ "${#semver[@]}" -eq 3 ]; then
minor="${semver[1]}" minor="${semver[1]}"
patch="${semver[2]}" patch="${semver[2]}"
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-${arch[1]}") for suffix in "latest" "$major" "$major.$minor" "$major.$minor.$patch"; do
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major-${arch[1]}") tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-${arch[1]}")
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor-${arch[1]}") tags_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-chromium-${arch[1]}")
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor.$patch-${arch[1]}") tags_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-libreoffice-${arch[1]}")
done
if [ "$platform" = "linux/amd64" ]; then if [ "$platform" = "linux/amd64" ]; then
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-cloudrun") for suffix in "latest" "$major" "$major.$minor" "$major.$minor.$patch"; do
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major-cloudrun") tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-cloudrun")
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor-cloudrun") tags_cloud_run_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-chromium-cloudrun")
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor.$patch-cloudrun") tags_cloud_run_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-libreoffice-cloudrun")
done
fi fi
if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-aws-lambda-${arch[1]}") for suffix in "latest" "$major" "$major.$minor" "$major.$minor.$patch"; do
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major-aws-lambda-${arch[1]}") tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-aws-lambda-${arch[1]}")
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor-aws-lambda-${arch[1]}") tags_aws_lambda_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-chromium-aws-lambda-${arch[1]}")
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor.$patch-aws-lambda-${arch[1]}") tags_aws_lambda_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-libreoffice-aws-lambda-${arch[1]}")
done
fi fi
else else
echo echo
echo "Non-semver version detected, fallback to $version" echo "Non-semver version detected, fallback to $version"
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-${arch[1]}") tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-${arch[1]}")
tags_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-chromium-${arch[1]}")
tags_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-libreoffice-${arch[1]}")
if [ "$platform" = "linux/amd64" ]; then if [ "$platform" = "linux/amd64" ]; then
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-cloudrun") tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-cloudrun")
tags_cloud_run_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-chromium-cloudrun")
tags_cloud_run_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-libreoffice-cloudrun")
fi fi
if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-aws-lambda-${arch[1]}") tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-aws-lambda-${arch[1]}")
tags_aws_lambda_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-chromium-aws-lambda-${arch[1]}")
tags_aws_lambda_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-libreoffice-aws-lambda-${arch[1]}")
fi fi
fi fi
tags_flags=() tags_flags=()
tags_chromium_flags=()
tags_libreoffice_flags=()
tags_cloud_run_flags=() tags_cloud_run_flags=()
tags_cloud_run_chromium_flags=()
tags_cloud_run_libreoffice_flags=()
tags_aws_lambda_flags=() tags_aws_lambda_flags=()
tags_aws_lambda_chromium_flags=()
tags_aws_lambda_libreoffice_flags=()
echo "Will use the following tags:" echo "Will use the following tags:"
for tag in "${tags[@]}"; do for tag in "${tags[@]}"; do
tags_flags+=("-t" "$tag") tags_flags+=("-t" "$tag")
echo "- $tag" echo "- $tag"
done done
for tag in "${tags_chromium[@]}"; do
tags_chromium_flags+=("-t" "$tag")
echo "- $tag"
done
for tag in "${tags_libreoffice[@]}"; do
tags_libreoffice_flags+=("-t" "$tag")
echo "- $tag"
done
for tag in "${tags_cloud_run[@]}"; do for tag in "${tags_cloud_run[@]}"; do
tags_cloud_run_flags+=("-t" "$tag") tags_cloud_run_flags+=("-t" "$tag")
echo "- $tag" echo "- $tag"
done done
for tag in "${tags_cloud_run_chromium[@]}"; do
tags_cloud_run_chromium_flags+=("-t" "$tag")
echo "- $tag"
done
for tag in "${tags_cloud_run_libreoffice[@]}"; do
tags_cloud_run_libreoffice_flags+=("-t" "$tag")
echo "- $tag"
done
for tag in "${tags_aws_lambda[@]}"; do for tag in "${tags_aws_lambda[@]}"; do
tags_aws_lambda_flags+=("-t" "$tag") tags_aws_lambda_flags+=("-t" "$tag")
echo "- $tag" echo "- $tag"
done done
for tag in "${tags_aws_lambda_chromium[@]}"; do
tags_aws_lambda_chromium_flags+=("-t" "$tag")
echo "- $tag"
done
for tag in "${tags_aws_lambda_libreoffice[@]}"; do
tags_aws_lambda_libreoffice_flags+=("-t" "$tag")
echo "- $tag"
done
echo echo
# Build images. # Build images.
@@ -145,7 +191,9 @@ join() {
no_arch_tag="$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version" no_arch_tag="$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version"
# Full variant.
cmd="docker buildx build \ cmd="docker buildx build \
--target gotenberg \
--build-arg GOTENBERG_VERSION=$version \ --build-arg GOTENBERG_VERSION=$version \
--platform $platform \ --platform $platform \
--load \ --load \
@@ -155,30 +203,102 @@ cmd="docker buildx build \
" "
run_cmd "$cmd" run_cmd "$cmd"
# Chromium only variant.
cmd="docker buildx build \
--target gotenberg-chromium \
--build-arg GOTENBERG_VERSION=$version \
--platform $platform \
--load \
${tags_chromium_flags[*]} \
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
"
run_cmd "$cmd"
# LibreOffice only variant.
cmd="docker buildx build \
--target gotenberg-libreoffice \
--build-arg GOTENBERG_VERSION=$version \
--platform $platform \
--load \
${tags_libreoffice_flags[*]} \
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
"
run_cmd "$cmd"
# Cloud Run variants (amd64 only).
if [ "$platform" = "linux/amd64" ]; then if [ "$platform" = "linux/amd64" ]; then
cmd="docker build \ cmd="docker buildx build \
--build-arg DOCKER_REGISTRY=$DOCKER_REGISTRY \ --target gotenberg-cloudrun \
--build-arg DOCKER_REPOSITORY=$DOCKER_REPOSITORY \
--build-arg GOTENBERG_VERSION=$version \ --build-arg GOTENBERG_VERSION=$version \
--platform $platform \
--load \
${tags_cloud_run_flags[*]} \ ${tags_cloud_run_flags[*]} \
-f $DOCKERFILE_CLOUDRUN $DOCKER_BUILD_CONTEXT -f $DOCKERFILE $DOCKER_BUILD_CONTEXT
"
run_cmd "$cmd"
cmd="docker buildx build \
--target gotenberg-cloudrun-chromium \
--build-arg GOTENBERG_VERSION=$version \
--platform $platform \
--load \
${tags_cloud_run_chromium_flags[*]} \
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
"
run_cmd "$cmd"
cmd="docker buildx build \
--target gotenberg-cloudrun-libreoffice \
--build-arg GOTENBERG_VERSION=$version \
--platform $platform \
--load \
${tags_cloud_run_libreoffice_flags[*]} \
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
" "
run_cmd "$cmd" run_cmd "$cmd"
fi fi
# AWS Lambda variants (amd64 + arm64 only).
if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then
cmd="docker build \ cmd="docker buildx build \
--build-arg DOCKER_REGISTRY=$DOCKER_REGISTRY \ --target gotenberg-aws-lambda \
--build-arg DOCKER_REPOSITORY=$DOCKER_REPOSITORY \ --build-arg GOTENBERG_VERSION=$version \
--build-arg GOTENBERG_VERSION=$version \ --platform $platform \
${tags_aws_lambda_flags[*]} \ --load \
-f $DOCKERFILE_AWS_LAMBDA $DOCKER_BUILD_CONTEXT ${tags_aws_lambda_flags[*]} \
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
"
run_cmd "$cmd"
cmd="docker buildx build \
--target gotenberg-aws-lambda-chromium \
--build-arg GOTENBERG_VERSION=$version \
--platform $platform \
--load \
${tags_aws_lambda_chromium_flags[*]} \
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
"
run_cmd "$cmd"
cmd="docker buildx build \
--target gotenberg-aws-lambda-libreoffice \
--build-arg GOTENBERG_VERSION=$version \
--platform $platform \
--load \
${tags_aws_lambda_libreoffice_flags[*]} \
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
" "
run_cmd "$cmd" run_cmd "$cmd"
fi fi
echo "✅ Done!" echo "✅ Done!"
echo "tags=$(join "," "${tags[@]}")" >> "$GITHUB_OUTPUT" echo "tags=$(join "," "${tags[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_chromium=$(join "," "${tags_chromium[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_libreoffice=$(join "," "${tags_libreoffice[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_cloud_run=$(join "," "${tags_cloud_run[@]}")" >> "$GITHUB_OUTPUT" echo "tags_cloud_run=$(join "," "${tags_cloud_run[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_cloud_run_chromium=$(join "," "${tags_cloud_run_chromium[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_cloud_run_libreoffice=$(join "," "${tags_cloud_run_libreoffice[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_aws_lambda=$(join "," "${tags_aws_lambda[@]}")" >> "$GITHUB_OUTPUT" echo "tags_aws_lambda=$(join "," "${tags_aws_lambda[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_aws_lambda_chromium=$(join "," "${tags_aws_lambda_chromium[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_aws_lambda_libreoffice=$(join "," "${tags_aws_lambda_libreoffice[@]}")" >> "$GITHUB_OUTPUT"
exit 0 exit 0

View File

@@ -23,9 +23,12 @@ runs:
env: env:
DOCKERHUB_USERNAME: ${{ inputs.docker_hub_username }} DOCKERHUB_USERNAME: ${{ inputs.docker_hub_username }}
DOCKERHUB_TOKEN: ${{ inputs.docker_hub_password }} DOCKERHUB_TOKEN: ${{ inputs.docker_hub_password }}
INPUT_TAGS: ${{ inputs.tags }}
INPUT_SNAPSHOT_VERSION: ${{ inputs.snapshot_version }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
shell: bash shell: bash
run: | run: |
.github/actions/clean/clean.sh \ .github/actions/clean/clean.sh \
--tags "${{ inputs.tags }}" \ --tags "$INPUT_TAGS" \
--snapshot-version "${{ inputs.snapshot_version }}" \ --snapshot-version "$INPUT_SNAPSHOT_VERSION" \
--dry-run "${{ inputs.dry_run }}" --dry-run "$INPUT_DRY_RUN"

View File

@@ -31,7 +31,7 @@ runs:
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Check out code - name: Check out code
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v3
@@ -41,8 +41,12 @@ runs:
- name: Merge - name: Merge
shell: bash shell: bash
env:
INPUT_TAGS: ${{ inputs.tags }}
INPUT_ALTERNATE_REGISTRY: ${{ inputs.alternate_registry }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
run: | run: |
.github/actions/merge/merge.sh \ .github/actions/merge/merge.sh \
--tags "${{ inputs.tags }}" \ --tags "$INPUT_TAGS" \
--alternate-registry "${{ inputs.alternate_registry }}" \ --alternate-registry "$INPUT_ALTERNATE_REGISTRY" \
--dry-run "${{ inputs.dry_run }}" --dry-run "$INPUT_DRY_RUN"

View File

@@ -12,7 +12,15 @@ updates:
directory: "/" directory: "/"
schedule: schedule:
interval: "weekly" 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" - package-ecosystem: "npm"
directory: "/" directory: "/"
schedule: schedule:
interval: "weekly" interval: "weekly"
cooldown:
default-days: 7

View File

@@ -13,11 +13,17 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
tags: ${{ steps.build_push.outputs.tags }} tags: ${{ steps.build_push.outputs.tags }}
tags_chromium: ${{ steps.build_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }} tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }} tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build and push - name: Build and push
id: build_push id: build_push
@@ -34,11 +40,17 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
tags: ${{ steps.build_push.outputs.tags }} tags: ${{ steps.build_push.outputs.tags }}
tags_chromium: ${{ steps.build_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }} tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }} tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build and push - name: Build and push
id: build_push id: build_push
@@ -55,11 +67,17 @@ jobs:
runs-on: ubuntu-24.04-ppc64le runs-on: ubuntu-24.04-ppc64le
outputs: outputs:
tags: ${{ steps.build_push.outputs.tags }} tags: ${{ steps.build_push.outputs.tags }}
tags_chromium: ${{ steps.build_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }} tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }} tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build and push - name: Build and push
id: build_push id: build_push
@@ -76,11 +94,17 @@ jobs:
runs-on: ubuntu-24.04-arm runs-on: ubuntu-24.04-arm
outputs: outputs:
tags: ${{ steps.build_push.outputs.tags }} tags: ${{ steps.build_push.outputs.tags }}
tags_chromium: ${{ steps.build_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }} tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }} tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build and push - name: Build and push
id: build_push id: build_push
@@ -97,11 +121,17 @@ jobs:
runs-on: ubuntu-24.04-arm runs-on: ubuntu-24.04-arm
outputs: outputs:
tags: ${{ steps.build_push.outputs.tags }} tags: ${{ steps.build_push.outputs.tags }}
tags_chromium: ${{ steps.build_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }} tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }} tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build and push - name: Build and push
id: build_push id: build_push
@@ -124,7 +154,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Merge - name: Merge
uses: ./.github/actions/merge uses: ./.github/actions/merge
@@ -132,7 +162,20 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }} docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }} docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_ppc64le.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }}" tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_ppc64le.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }}"
alternate_registry: thecodingmachine
- name: Merge Chromium
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags_chromium }},${{ needs.release_386.outputs.tags_chromium }},${{ needs.release_ppc64le.outputs.tags_chromium }},${{ needs.release_arm64.outputs.tags_chromium }},${{ needs.release_arm_v7.outputs.tags_chromium }}"
- name: Merge LibreOffice
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags_libreoffice }},${{ needs.release_386.outputs.tags_libreoffice }},${{ needs.release_ppc64le.outputs.tags_libreoffice }},${{ needs.release_arm64.outputs.tags_libreoffice }},${{ needs.release_arm_v7.outputs.tags_libreoffice }}"
- name: Merge AWS Lambda - name: Merge AWS Lambda
uses: ./.github/actions/merge uses: ./.github/actions/merge
@@ -140,11 +183,24 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }} docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }} docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags_aws_lambda }},${{ needs.release_arm64.outputs.tags_aws_lambda }}" tags: "${{ needs.release_amd64.outputs.tags_aws_lambda }},${{ needs.release_arm64.outputs.tags_aws_lambda }}"
alternate_registry: thecodingmachine
- name: Merge AWS Lambda Chromium
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.release_arm64.outputs.tags_aws_lambda_chromium }}"
- name: Merge AWS Lambda LibreOffice
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.release_arm64.outputs.tags_aws_lambda_libreoffice }}"
- name: Clean - name: Clean
uses: ./.github/actions/clean uses: ./.github/actions/clean
with: with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }} docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }} docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_ppc64le.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }},${{ needs.release_amd64.outputs.tags_aws_lambda }},${{ needs.release_arm64.outputs.tags_aws_lambda }}" tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_ppc64le.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }},${{ needs.release_amd64.outputs.tags_chromium }},${{ needs.release_386.outputs.tags_chromium }},${{ needs.release_ppc64le.outputs.tags_chromium }},${{ needs.release_arm64.outputs.tags_chromium }},${{ needs.release_arm_v7.outputs.tags_chromium }},${{ needs.release_amd64.outputs.tags_libreoffice }},${{ needs.release_386.outputs.tags_libreoffice }},${{ needs.release_ppc64le.outputs.tags_libreoffice }},${{ needs.release_arm64.outputs.tags_libreoffice }},${{ needs.release_arm_v7.outputs.tags_libreoffice }},${{ needs.release_amd64.outputs.tags_aws_lambda }},${{ needs.release_arm64.outputs.tags_aws_lambda }},${{ needs.release_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.release_arm64.outputs.tags_aws_lambda_chromium }},${{ needs.release_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.release_arm64.outputs.tags_aws_lambda_libreoffice }}"

View File

@@ -21,32 +21,32 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
- name: Run linters - name: Run linters
uses: golangci/golangci-lint-action@v9 uses: golangci/golangci-lint-action@v9
with: with:
version: v2.5.0 version: v2.10.1
lint-prettier: lint-prettier:
name: Lint non-Golang codebase name: Lint non-Golang codebase
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version-file: .node-version node-version-file: .node-version
- name: Install Dependencies - name: Install Dependencies
run: npm i run: npm ci --ignore-scripts
- name: Run linters - name: Run linters
run: make lint-prettier run: make lint-prettier
@@ -59,10 +59,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
@@ -77,11 +77,17 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
tags: ${{ steps.build_test_push.outputs.tags }} tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }} tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }} tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build, test and push - name: Build, test and push
id: build_test_push id: build_test_push
@@ -101,11 +107,17 @@ jobs:
runs-on: ubuntu-24.04-ppc64le runs-on: ubuntu-24.04-ppc64le
outputs: outputs:
tags: ${{ steps.build_test_push.outputs.tags }} tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }} tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }} tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build, test and push - name: Build, test and push
id: build_test_push id: build_test_push
@@ -125,11 +137,17 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
tags: ${{ steps.build_test_push.outputs.tags }} tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }} tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }} tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build, test and push - name: Build, test and push
id: build_test_push id: build_test_push
@@ -149,11 +167,17 @@ jobs:
runs-on: ubuntu-24.04-arm runs-on: ubuntu-24.04-arm
outputs: outputs:
tags: ${{ steps.build_test_push.outputs.tags }} tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }} tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }} tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build, test and push - name: Build, test and push
id: build_test_push id: build_test_push
@@ -173,11 +197,17 @@ jobs:
runs-on: ubuntu-24.04-arm runs-on: ubuntu-24.04-arm
outputs: outputs:
tags: ${{ steps.build_test_push.outputs.tags }} tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }} tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }} tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build, test and push - name: Build, test and push
id: build_test_push id: build_test_push
@@ -223,7 +253,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Merge - name: Merge
uses: ./.github/actions/merge uses: ./.github/actions/merge
@@ -232,6 +262,20 @@ jobs:
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }} docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.snapshot_amd64.outputs.tags }},${{ needs.snapshot_386.outputs.tags }},${{ needs.snapshot_ppc64le.outputs.tags }},${{ needs.snapshot_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }}" tags: "${{ needs.snapshot_amd64.outputs.tags }},${{ needs.snapshot_386.outputs.tags }},${{ needs.snapshot_ppc64le.outputs.tags }},${{ needs.snapshot_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }}"
- name: Merge Chromium
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.snapshot_amd64.outputs.tags_chromium }},${{ needs.snapshot_386.outputs.tags_chromium }},${{ needs.snapshot_ppc64le.outputs.tags_chromium }},${{ needs.snapshot_arm64.outputs.tags_chromium }},${{ needs.snapshot_arm_v7.outputs.tags_chromium }}"
- name: Merge LibreOffice
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.snapshot_amd64.outputs.tags_libreoffice }},${{ needs.snapshot_386.outputs.tags_libreoffice }},${{ needs.snapshot_ppc64le.outputs.tags_libreoffice }},${{ needs.snapshot_arm64.outputs.tags_libreoffice }},${{ needs.snapshot_arm_v7.outputs.tags_libreoffice }}"
- name: Merge AWS Lambda - name: Merge AWS Lambda
uses: ./.github/actions/merge uses: ./.github/actions/merge
with: with:
@@ -239,12 +283,26 @@ jobs:
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }} docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.snapshot_amd64.outputs.tags_aws_lambda }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda }}" tags: "${{ needs.snapshot_amd64.outputs.tags_aws_lambda }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda }}"
- name: Merge AWS Lambda Chromium
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.snapshot_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda_chromium }}"
- name: Merge AWS Lambda LibreOffice
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.snapshot_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda_libreoffice }}"
- name: Clean - name: Clean
uses: ./.github/actions/clean uses: ./.github/actions/clean
with: with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }} docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }} docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.snapshot_amd64.outputs.tags }},${{ needs.snapshot_386.outputs.tags }},${{ needs.snapshot_ppc64le.outputs.tags }},${{ needs.snapshot_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }},${{ needs.snapshot_amd64.outputs.tags_aws_lambda }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda }}" tags: "${{ needs.snapshot_amd64.outputs.tags }},${{ needs.snapshot_386.outputs.tags }},${{ needs.snapshot_ppc64le.outputs.tags }},${{ needs.snapshot_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }},${{ needs.snapshot_amd64.outputs.tags_chromium }},${{ needs.snapshot_386.outputs.tags_chromium }},${{ needs.snapshot_ppc64le.outputs.tags_chromium }},${{ needs.snapshot_arm64.outputs.tags_chromium }},${{ needs.snapshot_arm_v7.outputs.tags_chromium }},${{ needs.snapshot_amd64.outputs.tags_libreoffice }},${{ needs.snapshot_386.outputs.tags_libreoffice }},${{ needs.snapshot_ppc64le.outputs.tags_libreoffice }},${{ needs.snapshot_arm64.outputs.tags_libreoffice }},${{ needs.snapshot_arm_v7.outputs.tags_libreoffice }},${{ needs.snapshot_amd64.outputs.tags_aws_lambda }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda }},${{ needs.snapshot_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda_chromium }},${{ needs.snapshot_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda_libreoffice }}"
edge_amd64: edge_amd64:
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
@@ -254,11 +312,17 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
tags: ${{ steps.build_test_push.outputs.tags }} tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }} tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }} tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build, test and push - name: Build, test and push
id: build_test_push id: build_test_push
@@ -277,11 +341,17 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
tags: ${{ steps.build_test_push.outputs.tags }} tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }} tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }} tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build, test and push - name: Build, test and push
id: build_test_push id: build_test_push
@@ -300,11 +370,17 @@ jobs:
runs-on: ubuntu-24.04-ppc64le runs-on: ubuntu-24.04-ppc64le
outputs: outputs:
tags: ${{ steps.build_test_push.outputs.tags }} tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }} tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }} tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build, test and push - name: Build, test and push
id: build_test_push id: build_test_push
@@ -323,11 +399,17 @@ jobs:
runs-on: ubuntu-24.04-arm runs-on: ubuntu-24.04-arm
outputs: outputs:
tags: ${{ steps.build_test_push.outputs.tags }} tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }} tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }} tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build, test and push - name: Build, test and push
id: build_test_push id: build_test_push
@@ -346,11 +428,17 @@ jobs:
runs-on: ubuntu-24.04-arm runs-on: ubuntu-24.04-arm
outputs: outputs:
tags: ${{ steps.build_test_push.outputs.tags }} tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }} tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }} tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build, test and push - name: Build, test and push
id: build_test_push id: build_test_push
@@ -372,7 +460,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Merge - name: Merge
uses: ./.github/actions/merge uses: ./.github/actions/merge
@@ -380,7 +468,20 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }} docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }} docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_ppc64le.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }}" tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_ppc64le.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }}"
alternate_registry: thecodingmachine
- name: Merge Chromium
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags_chromium }},${{ needs.edge_386.outputs.tags_chromium }},${{ needs.edge_ppc64le.outputs.tags_chromium }},${{ needs.edge_arm64.outputs.tags_chromium }},${{ needs.edge_arm_v7.outputs.tags_chromium }}"
- name: Merge LibreOffice
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags_libreoffice }},${{ needs.edge_386.outputs.tags_libreoffice }},${{ needs.edge_ppc64le.outputs.tags_libreoffice }},${{ needs.edge_arm64.outputs.tags_libreoffice }},${{ needs.edge_arm_v7.outputs.tags_libreoffice }}"
- name: Merge AWS Lambda - name: Merge AWS Lambda
uses: ./.github/actions/merge uses: ./.github/actions/merge
@@ -388,11 +489,24 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }} docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }} docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags_aws_lambda }},${{ needs.edge_arm64.outputs.tags_aws_lambda }}" tags: "${{ needs.edge_amd64.outputs.tags_aws_lambda }},${{ needs.edge_arm64.outputs.tags_aws_lambda }}"
alternate_registry: thecodingmachine
- name: Merge AWS Lambda Chromium
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.edge_arm64.outputs.tags_aws_lambda_chromium }}"
- name: Merge AWS Lambda LibreOffice
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.edge_arm64.outputs.tags_aws_lambda_libreoffice }}"
- name: Clean - name: Clean
uses: ./.github/actions/clean uses: ./.github/actions/clean
with: with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }} docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }} docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_ppc64le.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }},${{ needs.edge_amd64.outputs.tags_aws_lambda }},${{ needs.edge_arm64.outputs.tags_aws_lambda }}" tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_ppc64le.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }},${{ needs.edge_amd64.outputs.tags_chromium }},${{ needs.edge_386.outputs.tags_chromium }},${{ needs.edge_ppc64le.outputs.tags_chromium }},${{ needs.edge_arm64.outputs.tags_chromium }},${{ needs.edge_arm_v7.outputs.tags_chromium }},${{ needs.edge_amd64.outputs.tags_libreoffice }},${{ needs.edge_386.outputs.tags_libreoffice }},${{ needs.edge_ppc64le.outputs.tags_libreoffice }},${{ needs.edge_arm64.outputs.tags_libreoffice }},${{ needs.edge_arm_v7.outputs.tags_libreoffice }},${{ needs.edge_amd64.outputs.tags_aws_lambda }},${{ needs.edge_arm64.outputs.tags_aws_lambda }},${{ needs.edge_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.edge_arm64.outputs.tags_aws_lambda_chromium }},${{ needs.edge_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.edge_arm64.outputs.tags_aws_lambda_libreoffice }}"

View File

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

View File

@@ -24,13 +24,14 @@ linters:
- ineffassign - ineffassign
- misspell - misspell
- prealloc - prealloc
- promlinter
- staticcheck - staticcheck
- testableexamples - testableexamples
- tparallel - tparallel
- unconvert - unconvert
- unused - unused
- sloglint
- usetesting - usetesting
- gocritic
- wastedassign - wastedassign
- whitespace - whitespace
exclusions: exclusions:
@@ -40,10 +41,6 @@ linters:
- common-false-positives - common-false-positives
- legacy - legacy
- std-error-handling - std-error-handling
paths:
- third_party$
- builtin$
- examples$
formatters: formatters:
enable: enable:
- gci - gci
@@ -59,7 +56,3 @@ formatters:
custom-order: true custom-order: true
exclusions: exclusions:
generated: lax generated: lax
paths:
- third_party$
- builtin$
- examples$

View File

@@ -1 +1 @@
24.11.0 24.15.0

1
.npmrc Normal file
View File

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

1
AGENTS.md Symbolic link
View File

@@ -0,0 +1 @@
CONTRIBUTING.md

1
CLAUDE.md Symbolic link
View File

@@ -0,0 +1 @@
CONTRIBUTING.md

195
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,195 @@
# Contributing to Gotenberg
Gotenberg is a Docker-based API for converting documents to PDF. Two rules override everything else:
- **Backward compatibility.** Never rename or remove CLI flags, environment variables, API form fields, or HTTP endpoints without discussion.
- **Defensive programming.** Assume input is malformed, handle errors explicitly, never panic.
## Toolchain
- Module: `github.com/gotenberg/gotenberg/v8`
- Go: see version in `go.mod`
- Docker
- Node.js (see `.node-version`), for Prettier linting
- [golangci-lint](https://golangci-lint.run/) v2+
## Before you start
For non-trivial changes, open an issue or a draft PR first. Describe what needs to change, the proposed solution (files to modify, interface changes, form fields), and which integration test tags are affected.
One thing per PR. Keep features, bug fixes, and refactoring in separate PRs.
When adding a feature or route, write the Gherkin scenario before the Go code, and plan to update the Bruno collection (`.bruno/`) if a route changes.
## Project layout
```
cmd/gotenberg/ -> Entry point only (wiring/startup). No business logic.
pkg/gotenberg/ -> Core module system, interfaces, utilities, mocks.
pkg/modules/ -> Feature modules (api, chromium, libreoffice, pdfengines, etc.).
pkg/standard/ -> Wires all standard modules together via imports.
test/integration/ -> Gherkin feature files + Go test infrastructure.
build/ -> Dockerfile, fonts, Chromium config.
.bruno/ -> Bruno API collection (mirrors every route).
```
Key interfaces live in `pkg/gotenberg/`: `Module`, `Provisioner`, `Validator`, `Debuggable`. Every module implements `Descriptor()` and self-registers via `init()`.
## Setup and Makefile
All build and verification tasks go through the Makefile. Do not run `go` commands directly unless debugging a specific package.
| Command | Purpose | When to use |
| ----------------------- | ------------------------------------------------ | ------------------------------------------------------------------------ |
| `make build` | Build the Gotenberg Docker image | Before integration tests or manual testing |
| `make run` | Run a Gotenberg container via `docker compose` | Manual testing. Flags configured via Makefile variables and compose.yaml |
| `make telemetry` | Start an OpenTelemetry collector and OpenObserve | When testing telemetry locally |
| `make down` | Stop all compose containers | After manual testing |
| `make godoc` | Serve GoDoc at `localhost:6060` | To verify documentation |
| `make fmt` | Format Go code | Before committing |
| `make lint` | Lint Go code (zero errors permitted) | Before committing |
| `make prettify` | Format non-Go files (Markdown, YAML, JSON) | Before committing |
| `make lint-prettier` | Lint non-Go files | Before committing |
| `make test-unit` | Run unit tests | Before committing |
| `make test-integration` | Run all integration tests (40 min timeout) | Before committing |
Run only the integration test tag(s) relevant to your change rather than the full suite:
```bash
make test-integration TAGS=health
make test-integration TAGS=chromium-convert-html
make test-integration TAGS="merge,split"
```
## Code conventions
### Module system
Gotenberg uses a self-registering module architecture inspired by CaddyServer. Each module lives in `pkg/modules/<name>/`, implements at minimum `gotenberg.Module` (`Descriptor()`), and self-registers via `init()`. Wiring happens through `pkg/standard/`.
Determine if a feature belongs in an existing module before creating a new one. Only create a new module for a genuinely separate concern.
The `cmd/gotenberg/` package is strictly for wiring and startup. No business logic.
### Backward compatibility
CLI flags, environment variables, API form fields, HTTP endpoints, and default values that alter existing behavior must not change without discussion. Deprecate old names with `fs.MarkDeprecated()` and register both the old and new names side by side.
If a change violates backward compatibility, flag it as a breaking change in the PR description.
### Error handling
- Wrap every error with context: `fmt.Errorf("description: %w", err)`.
- Never swallow errors silently.
- Match errors with `errors.Is`, never `strings.Contains`.
- No panics in production code paths.
- Validate input defensively.
### 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.
### Telemetry
External tool calls (Chromium, LibreOffice, PDF engines, webhooks, downloads) must create OTEL spans with `trace.SpanKindClient` and `semconv.ServerAddress("toolname")`. Use `gotenberg.Tracer()` and `gotenberg.Meter()` for traces and metrics.
### Import ordering
Enforced by `gci`: standard library, then third-party, then `github.com/gotenberg/gotenberg/v8`. Three groups separated by blank lines.
## Documentation conventions
### Tone
- Short, declarative sentences. Say what it does, then stop.
- Lead with the action. "Validates font embedding", not "This function validates font embedding".
- Active voice. "Gotenberg checks the profile", not "The profile is checked by Gotenberg".
- No em dashes. Use a period, colon, or comma.
- No "we" hedging. "Don't...", not "We do not recommend...".
### Godoc
Every exported type and function has a Godoc comment starting with its identifier name:
```go
// OutboundDecision is the result of validating an outbound URL via
// [DecideOutbound]. ...
type OutboundDecision struct { ... }
// DialPinned dials each addr in turn until one connects, returning the
// first successful connection or the last error. ...
func DialPinned(ctx context.Context, network string, addrs []netip.Addr, port string) (net.Conn, error)
```
Each package should have a `doc.go` with a `// Package foo ...` comment:
```go
// Package api manages a LibreOffice instance via the UNO API.
package api
```
Reference identifiers with `[Name]` brackets for pkg.go.dev linking:
```go
// Callers pass the Pinned slice from [OutboundDecision] so that the dial
// targets exactly the IPs that [DecideOutbound] resolved, preventing DNS
// rebinding between validation and connect.
```
### Code comments
- Explain _why_, not _what_.
- No numbered step comments (`// 1. Do X`, `// 2. Do Y`).
- No section dividers with numbers (`// --- 8. Foo ---`). Plain dividers are fine for major boundaries.
- No noise comments that restate the code (`// Check if err is nil`, `// Return results`).
- Reference spec clauses where relevant (`// Per ISO 32000-2, Table 116...`).
- Mark debt with `// TODO: [context]`.
## Testing
### Unit tests
Table-driven tests in `*_test.go` files. Use the comprehensive mock implementations in `pkg/gotenberg/mocks.go` rather than rolling new ones.
### Integration tests
Gherkin (BDD) via Godog with `testcontainers-go` for Docker orchestration. Feature files live in `test/integration/features/`; step definitions live in `test/integration/scenario/`. Read `scenario.go` and `containers.go` before writing new tests.
`make build` is required before running integration tests. The full suite has a 40-minute timeout, so run only the tag(s) relevant to your change.
## Pull requests
### Commits
[Conventional Commits](https://www.conventionalcommits.org/): `<type>(<scope>): <description>`.
Common types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `ci`, `build`. The scope matches the module or area of the change (e.g., `chromium`, `pdfengines`, `api`).
Stage specific files. Never `git add -A` or `git add .`.
### Checklist
Before opening the PR, confirm:
- [ ] No backward-compatibility regression. See [Backward compatibility](#backward-compatibility).
- [ ] Code conventions met (error wrapping, logging, telemetry, import ordering, no panics, no business logic in `cmd/`). See [Code conventions](#code-conventions).
- [ ] Documentation conventions met (Godoc on every exported identifier, `doc.go` for new packages, tone). See [Documentation conventions](#documentation-conventions).
- [ ] `make fmt && make lint && make prettify && make lint-prettier` pass with zero warnings.
- [ ] `make test-unit` passes.
- [ ] Relevant `make test-integration TAGS=...` passes.
- [ ] Bruno collection updated if routes were added or modified.
## Further reading
- [`test/integration/README.md`](test/integration/README.md) — Gherkin step reference, available tags, writing new tests.
- [`.bruno/README.md`](.bruno/README.md) — `.bru` file format, conventions, route update checklist.
- [`pkg/modules/pdfengines/README.md`](pkg/modules/pdfengines/README.md) — adding new engine features (Makefile variable and flag).

1
GEMINI.md Symbolic link
View File

@@ -0,0 +1 @@
CONTRIBUTING.md

174
Makefile
View File

@@ -5,33 +5,45 @@ help: ## Show the help
@grep -hE '^[A-Za-z0-9_ \-]*?:.*##.*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' @grep -hE '^[A-Za-z0-9_ \-]*?:.*##.*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.PHONY: build .PHONY: build
build: ## Build the Gotenberg's Docker image build: ## Build the Gotenberg's Docker image (use TARGET=gotenberg-chromium or TARGET=gotenberg-libreoffice for variants)
docker build \ docker build \
--target $(TARGET) \
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \ -t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
-f $(DOCKERFILE) $(DOCKER_BUILD_CONTEXT) -f $(DOCKERFILE) $(DOCKER_BUILD_CONTEXT)
TZ=UTC
GOTENBERG_HIDE_BANNER=false GOTENBERG_HIDE_BANNER=false
GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s
GOTENBERG_BUILD_DEBUG_DATA=true GOTENBERG_BUILD_DEBUG_DATA=true
API_PORT=3000 API_PORT=3000
API_PORT_FROM_ENV= API_PORT_FROM_ENV=
API_BIND_IP= API_BIND_IP=
API_TLS_CERT_FILE=
API_TLS_KEY_FILE=
API_START_TIMEOUT=30s API_START_TIMEOUT=30s
API_TIMEOUT=30s API_TIMEOUT=30s
API_BODY_LIMIT= API_BODY_LIMIT=
API_ROOT_PATH="/" API_ROOT_PATH=/
API_TRACE_HEADER=Gotenberg-Trace API_CORRELATION_ID_HEADER=Gotenberg-Trace
API_ENABLE_BASIC_AUTH=false API_ENABLE_BASIC_AUTH=false
GOTENBERG_API_BASIC_AUTH_USERNAME= GOTENBERG_API_BASIC_AUTH_USERNAME=
GOTENBERG_API_BASIC_AUTH_PASSWORD= GOTENBERG_API_BASIC_AUTH_PASSWORD=
API-DOWNLOAD-FROM-ALLOW-LIST= API_DOWNLOAD_FROM_ALLOW_LIST=
API-DOWNLOAD-FROM-DENY-LIST= API_DOWNLOAD_FROM_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)
API-DOWNLOAD-FROM-FROM-MAX-RETRY=4 API_DOWNLOAD_FROM_DENY_PRIVATE_IPS=false
API-DISABLE-DOWNLOAD-FROM=false API_DOWNLOAD_FROM_DENY_PUBLIC_IPS=false
API_DISABLE_HEALTH_CHECK_LOGGING=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
API_DISABLE_ROOT_ROUTE_TELEMETRY=true
API_DISABLE_DEBUG_ROUTE_TELEMETRY=true
API_DISABLE_VERSION_ROUTE_TELEMETRY=true
API_ENABLE_DEBUG_ROUTE=false API_ENABLE_DEBUG_ROUTE=false
CHROMIUM_RESTART_AFTER=10 CHROMIUM_RESTART_AFTER=100
CHROMIUM_MAX_QUEUE_SIZE=0 CHROMIUM_MAX_QUEUE_SIZE=0
CHROMIUM_IDLE_SHUTDOWN_TIMEOUT=0
CHROMIUM_MAX_CONCURRENCY=6
CHROMIUM_AUTO_START=false CHROMIUM_AUTO_START=false
CHROMIUM_START_TIMEOUT=20s CHROMIUM_START_TIMEOUT=20s
CHROMIUM_ALLOW_INSECURE_LOCALHOST=false CHROMIUM_ALLOW_INSECURE_LOCALHOST=false
@@ -40,121 +52,85 @@ CHROMIUM_DISABLE_WEB_SECURITY=false
CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false
CHROMIUM_HOST_RESOLVER_RULES= CHROMIUM_HOST_RESOLVER_RULES=
CHROMIUM_PROXY_SERVER= CHROMIUM_PROXY_SERVER=
CHROMIUM_ENABLE_ENVIRONMENT_PROXY=false
CHROMIUM_ALLOW_LIST= CHROMIUM_ALLOW_LIST=
CHROMIUM_DENY_LIST=^file:(?!//\/tmp/).* CHROMIUM_DENY_LIST=^file:(?!//\/tmp/).*
CHROMIUM_DENY_PRIVATE_IPS=false
CHROMIUM_DENY_PUBLIC_IPS=false
CHROMIUM_CLEAR_CACHE=false CHROMIUM_CLEAR_CACHE=false
CHROMIUM_CLEAR_COOKIES=false CHROMIUM_CLEAR_COOKIES=false
CHROMIUM_DISABLE_JAVASCRIPT=false CHROMIUM_DISABLE_JAVASCRIPT=false
CHROMIUM_DISABLE_ROUTES=false CHROMIUM_DISABLE_ROUTES=false
LIBREOFFICE_RESTART_AFTER=10 LIBREOFFICE_RESTART_AFTER=10
LIBREOFFICE_MAX_QUEUE_SIZE=0 LIBREOFFICE_MAX_QUEUE_SIZE=0
LIBREOFFICE_IDLE_SHUTDOWN_TIMEOUT=0
LIBREOFFICE_AUTO_START=false LIBREOFFICE_AUTO_START=false
LIBREOFFICE_START_TIMEOUT=20s 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 LIBREOFFICE_DISABLE_ROUTES=false
LOG_LEVEL=info LOG_LEVEL=info
LOG_FORMAT=auto
LOG_FIELDS_PREFIX= LOG_FIELDS_PREFIX=
LOG_ENABLE_GCP_FIELDS=false 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_MERGE_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
PDFENGINES_FLATTEN_ENGINES=qpdf PDFENGINES_FLATTEN_ENGINES=qpdf
PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
PDFENGINES_READ_METADATA_ENGINES=exiftool PDFENGINES_READ_METADATA_ENGINES=exiftool
PDFENGINES_WRITE_METADATA_ENGINES=exiftool PDFENGINES_WRITE_METADATA_ENGINES=exiftool
PDFENGINES_READ_BOOKMARKS_ENGINES=pdfcpu
PDFENGINES_WRITE_BOOKMARKS_ENGINES=pdfcpu
PDFENGINES_WATERMARK_ENGINES=pdfcpu,pdftk
PDFENGINES_STAMP_ENGINES=pdfcpu,pdftk
PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_DISABLE_ROUTES=false PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
PDFENGINES_EMBED_ENGINES=pdfcpu PDFENGINES_EMBED_ENGINES=qpdf,pdfcpu
PDFENGINES_EMBED_METADATA_ENGINES=qpdf
PDFENGINES_FACTUR_X_ENGINES=qpdf
PROMETHEUS_NAMESPACE=gotenberg PROMETHEUS_NAMESPACE=gotenberg
PROMETHEUS_COLLECT_INTERVAL=1s PROMETHEUS_COLLECT_INTERVAL=1s
PROMETHEUS_DISABLE_ROUTE_LOGGING=false PROMETHEUS_DISABLE_ROUTE_TELEMETRY=true
PROMETHEUS_DISABLE_COLLECT=false PROMETHEUS_DISABLE_COLLECT=false
PROMETHEUS_METRICS_PATH=/prometheus/metrics PROMETHEUS_METRICS_PATH=/prometheus/metrics
OTEL_SERVICE_NAME=gotenberg
OTEL_TRACES_EXPORTER=none
OTEL_METRICS_EXPORTER=none
OTEL_LOGS_EXPORTER=none
OTEL_EXPORTER_OTLP_PROTOCOL=grpc
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
OTEL_EXPORTER_OTLP_INSECURE=true
WEBHOOK_ENABLE_SYNC_MODE=false WEBHOOK_ENABLE_SYNC_MODE=false
WEBHOOK_ALLOW_LIST= WEBHOOK_ALLOW_LIST=
WEBHOOK_DENY_LIST= WEBHOOK_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)
WEBHOOK_ERROR_ALLOW_LIST= WEBHOOK_DENY_PRIVATE_IPS=false
WEBHOOK_ERROR_DENY_LIST= WEBHOOK_DENY_PUBLIC_IPS=false
WEBHOOK_ENABLE_ENVIRONMENT_PROXY=false
WEBHOOK_MAX_RETRY=4 WEBHOOK_MAX_RETRY=4
WEBHOOK_RETRY_MIN_WAIT=1s WEBHOOK_RETRY_MIN_WAIT=1s
WEBHOOK_RETRY_MAX_WAIT=30s WEBHOOK_RETRY_MAX_WAIT=30s
WEBHOOK_CLIENT_TIMEOUT=30s WEBHOOK_CLIENT_TIMEOUT=30s
WEBHOOK_DISABLE=false WEBHOOK_DISABLE=false
# Export all variables so they are available to Compose
export
.PHONY: run .PHONY: run
run: ## Start a Gotenberg container run: ## Start a Gotenberg container via Compose
docker run --rm -it \ docker compose up gotenberg
-p $(API_PORT):$(API_PORT) \
-e GOTENBERG_API_BASIC_AUTH_USERNAME=$(GOTENBERG_API_BASIC_AUTH_USERNAME) \ .PHONY: telemetry
-e GOTENBERG_API_BASIC_AUTH_PASSWORD=$(GOTENBERG_API_BASIC_AUTH_PASSWORD) \ telemetry: ## Start an OpenTelemetry collector and OpenObserve containers via Compose
$(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \ docker compose up otel-collector openobserve
gotenberg \
--gotenberg-hide-banner=$(GOTENBERG_HIDE_BANNER) \ .PHONY: down
--gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \ down: ## Stop all containers
--gotenberg-build-debug-data="$(GOTENBERG_BUILD_DEBUG_DATA)" \ docker compose down -v
--api-port=$(API_PORT) \
--api-port-from-env=$(API_PORT_FROM_ENV) \
--api-bind-ip=$(API_BIND_IP) \
--api-start-timeout=$(API_START_TIMEOUT) \
--api-timeout=$(API_TIMEOUT) \
--api-body-limit="$(API_BODY_LIMIT)" \
--api-root-path=$(API_ROOT_PATH) \
--api-trace-header=$(API_TRACE_HEADER) \
--api-enable-basic-auth=$(API_ENABLE_BASIC_AUTH) \
--api-download-from-allow-list=$(API-DOWNLOAD-FROM-ALLOW-LIST) \
--api-download-from-deny-list=$(API-DOWNLOAD-FROM-DENY-LIST) \
--api-download-from-max-retry=$(API-DOWNLOAD-FROM-FROM-MAX-RETRY) \
--api-disable-download-from=$(API-DISABLE-DOWNLOAD-FROM) \
--api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \
--api-enable-debug-route=$(API_ENABLE_DEBUG_ROUTE) \
--chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \
--chromium-auto-start=$(CHROMIUM_AUTO_START) \
--chromium-max-queue-size=$(CHROMIUM_MAX_QUEUE_SIZE) \
--chromium-start-timeout=$(CHROMIUM_START_TIMEOUT) \
--chromium-allow-insecure-localhost=$(CHROMIUM_ALLOW_INSECURE_LOCALHOST) \
--chromium-ignore-certificate-errors=$(CHROMIUM_IGNORE_CERTIFICATE_ERRORS) \
--chromium-disable-web-security=$(CHROMIUM_DISABLE_WEB_SECURITY) \
--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-allow-list="$(CHROMIUM_ALLOW_LIST)" \
--chromium-deny-list="$(CHROMIUM_DENY_LIST)" \
--chromium-clear-cache=$(CHROMIUM_CLEAR_CACHE) \
--chromium-clear-cookies=$(CHROMIUM_CLEAR_COOKIES) \
--chromium-disable-javascript=$(CHROMIUM_DISABLE_JAVASCRIPT) \
--chromium-disable-routes=$(CHROMIUM_DISABLE_ROUTES) \
--libreoffice-restart-after=$(LIBREOFFICE_RESTART_AFTER) \
--libreoffice-max-queue-size=$(LIBREOFFICE_MAX_QUEUE_SIZE) \
--libreoffice-auto-start=$(LIBREOFFICE_AUTO_START) \
--libreoffice-start-timeout=$(LIBREOFFICE_START_TIMEOUT) \
--libreoffice-disable-routes=$(LIBREOFFICE_DISABLE_ROUTES) \
--log-level=$(LOG_LEVEL) \
--log-format=$(LOG_FORMAT) \
--log-fields-prefix=$(LOG_FIELDS_PREFIX) \
--log-enable-gcp-fields=$(LOG_ENABLE_GCP_FIELDS) \
--pdfengines-merge-engines=$(PDFENGINES_MERGE_ENGINES) \
--pdfengines-split-engines=$(PDFENGINES_SPLIT_ENGINES) \
--pdfengines-flatten-engines=$(PDFENGINES_FLATTEN_ENGINES) \
--pdfengines-convert-engines=$(PDFENGINES_CONVERT_ENGINES) \
--pdfengines-read-metadata-engines=$(PDFENGINES_READ_METADATA_ENGINES) \
--pdfengines-write-metadata-engines=$(PDFENGINES_WRITE_METADATA_ENGINES) \
--pdfengines-encrypt-engines=$(PDFENGINES_ENCRYPT_ENGINES) \
--pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \
--pdfengines-embed-engines=$(PDFENGINES_EMBED_ENGINES) \
--prometheus-namespace=$(PROMETHEUS_NAMESPACE) \
--prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \
--prometheus-disable-route-logging=$(PROMETHEUS_DISABLE_ROUTE_LOGGING) \
--prometheus-disable-collect=$(PROMETHEUS_DISABLE_COLLECT) \
--prometheus-metrics-path=$(PROMETHEUS_METRICS_PATH) \
--webhook-enable-sync-mode="$(WEBHOOK_ENABLE_SYNC_MODE)" \
--webhook-allow-list="$(WEBHOOK_ALLOW_LIST)" \
--webhook-deny-list="$(WEBHOOK_DENY_LIST)" \
--webhook-error-allow-list=$(WEBHOOK_ERROR_ALLOW_LIST) \
--webhook-error-deny-list=$(WEBHOOK_ERROR_DENY_LIST) \
--webhook-max-retry=$(WEBHOOK_MAX_RETRY) \
--webhook-retry-min-wait=$(WEBHOOK_RETRY_MIN_WAIT) \
--webhook-retry-max-wait=$(WEBHOOK_RETRY_MAX_WAIT) \
--webhook-client-timeout=$(WEBHOOK_CLIENT_TIMEOUT) \
--webhook-disable=$(WEBHOOK_DISABLE)
.PHONY: test-unit .PHONY: test-unit
test-unit: ## Run unit tests test-unit: ## Run unit tests
@@ -164,9 +140,13 @@ PLATFORM=
NO_CONCURRENCY=false NO_CONCURRENCY=false
# Available tags: # Available tags:
# chromium # chromium
# chromium-concurrent
# chromium-convert-html # chromium-convert-html
# chromium-convert-markdown # chromium-convert-markdown
# chromium-convert-url # chromium-convert-url
# chromium-screenshot-html
# chromium-screenshot-markdown
# chromium-screenshot-url
# debug # debug
# health # health
# libreoffice # libreoffice
@@ -186,6 +166,15 @@ NO_CONCURRENCY=false
# metadata # metadata
# pdfengines-split # pdfengines-split
# split # split
# pdfengines-watermark
# watermark
# pdfengines-stamp
# stamp
# pdfengines-rotate
# rotate
# factur-x
# pdfengines-bookmarks
# bookmarks
# prometheus-metrics # prometheus-metrics
# root # root
# version # version
@@ -194,7 +183,7 @@ NO_CONCURRENCY=false
TAGS= TAGS=
.PHONY: test-integration .PHONY: test-integration
test-integration: ## Run integration tests test-integration: ## Run integration tests (automatically retries failed scenarios up to 3 times)
go test -timeout 40m -tags=integration -v github.com/gotenberg/gotenberg/v8/test/integration -args \ go test -timeout 40m -tags=integration -v github.com/gotenberg/gotenberg/v8/test/integration -args \
--gotenberg-docker-repository=$(DOCKER_REPOSITORY) \ --gotenberg-docker-repository=$(DOCKER_REPOSITORY) \
--gotenberg-version=$(GOTENBERG_VERSION) \ --gotenberg-version=$(GOTENBERG_VERSION) \
@@ -216,6 +205,7 @@ lint-todo: ## Find TODOs in Golang codebase
.PHONY: fmt .PHONY: fmt
fmt: ## Format Golang codebase and "optimize" the dependencies fmt: ## Format Golang codebase and "optimize" the dependencies
go fix ./...
golangci-lint fmt golangci-lint fmt
go mod tidy go mod tidy

View File

@@ -1,63 +1,72 @@
<p align="center"> <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> <h3 align="center">Gotenberg</h3>
<p align="center">A containerized API for seamless PDF conversion</p> <p align="center">A Docker-based API for converting documents to PDF</p>
<p align="center"> <p align="center">
<a href="https://hub.docker.com/r/gotenberg/gotenberg"><img alt="Total downloads (gotenberg/gotenberg)" src="https://img.shields.io/docker/pulls/gotenberg/gotenberg"></a> <a href="https://hub.docker.com/r/gotenberg/gotenberg"><img alt="Total downloads (gotenberg/gotenberg)" src="https://img.shields.io/docker/pulls/gotenberg/gotenberg"></a>
<a href="https://hub.docker.com/r/thecodingmachine/gotenberg"><img alt="Total downloads (thecodingmachine/gotenberg)" src="https://img.shields.io/docker/pulls/thecodingmachine/gotenberg"></a>
<a href="https://github.com/gotenberg/gotenberg/actions/workflows/continuous-integration.yml"><img alt="Continuous Integration" src="https://github.com/gotenberg/gotenberg/actions/workflows/continuous-integration.yml/badge.svg"></a> <a href="https://github.com/gotenberg/gotenberg/actions/workflows/continuous-integration.yml"><img alt="Continuous Integration" src="https://github.com/gotenberg/gotenberg/actions/workflows/continuous-integration.yml/badge.svg"></a>
<a href="https://pkg.go.dev/github.com/gotenberg/gotenberg/v8"><img alt="Go Reference" src="https://pkg.go.dev/badge/github.com/gotenberg/gotenberg.svg"></a> <a href="https://pkg.go.dev/github.com/gotenberg/gotenberg/v8"><img alt="Go Reference" src="https://pkg.go.dev/badge/github.com/gotenberg/gotenberg.svg"></a>
</p> </p>
<p align="center"> <p align="center">
<a href="https://trendshift.io/repositories/2996"><img src="https://trendshift.io/api/badge/repositories/2996" alt="gotenberg%2Fgotenberg | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/2996"><img src="https://trendshift.io/api/badge/repositories/2996" alt="gotenberg%2Fgotenberg | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p> </p>
<p align="center"><a href="https://gotenberg.dev/docs/getting-started/introduction">Documentation</a> &#183; <a href="https://gotenberg.dev/docs/getting-started/installation#live-demo-">Live Demo</a> 🔥</p> <p align="center">
<a href="https://gotenberg.dev/docs/getting-started/introduction"><strong>Documentation</strong></a> &#183;
<a href="https://gotenberg.dev/docs/getting-started/installation#live-demo"><strong>Live Demo</strong></a> &#183;
<a href="https://github.com/gotenberg/gotenberg/discussions"><strong>Community</strong></a>
</p>
</p> </p>
--- ---
**Gotenberg** provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting **Gotenberg** is a Docker-based API for converting documents to PDF. Trusted in production by thousands of companies. Also adopted by notable open-source projects.
numerous document formats (HTML, Markdown, Word, Excel, etc.) into PDF files, and more!
Send your files via `multipart/form-data`, get a PDF back. No need to manage Chromium, LibreOffice, or fonts yourself.
## Quick Start ## Quick Start
Open a terminal and run the following command: ```bash
```
docker run --rm -p 3000:3000 gotenberg/gotenberg:8 docker run --rm -p 3000:3000 gotenberg/gotenberg:8
``` ```
Alternatively, using the historic Docker repository from our sponsor [TheCodingMachine](https://www.thecodingmachine.com): Convert a URL to PDF:
``` ```bash
docker run --rm -p 3000:3000 thecodingmachine/gotenberg:8 curl \
--request POST http://localhost:3000/forms/chromium/convert/url \
--form url=https://sparksuite.github.io/simple-html-invoice-template/ \
-o invoice.pdf
``` ```
The API is now available on your host at http://localhost:3000. ## Features
Head to the [documentation](https://gotenberg.dev/docs/getting-started/introduction) to learn how to interact with it 🚀 - **HTML, URL, Markdown to PDF** via Headless Chromium
- **Office documents to PDF** via LibreOffice (100+ formats)
- **Merge, split, rotate, flatten** PDFs
- **Watermark, stamp, encrypt** PDFs
- **PDF/A and PDF/UA** compliance
- **Screenshots** of URLs and HTML
- **Read/write metadata and bookmarks**
See the [full documentation](https://gotenberg.dev/docs/getting-started/introduction).
## Contributing
Questions and feedback: [GitHub Discussions](https://github.com/gotenberg/gotenberg/discussions).
Bug reports: [GitHub Issues](https://github.com/gotenberg/gotenberg/issues).
## Sponsors ## Sponsors
<p align="center"> If Gotenberg powers your workflow or your business, consider [**becoming a sponsor**](https://github.com/sponsors/gulien).
<a href="https://thecodingmachine.com">
<img src="https://user-images.githubusercontent.com/8983173/130324668-9d6e7b35-53a3-49c7-a574-38190d2bd6b0.png" alt="TheCodingMachine Logo" width="333" height="163" />
</a>
<a href="https://pdfme.com?utm_source=gotenberg_github&utm_medium=website" target="_blank">
<img src="https://github.com/user-attachments/assets/2a75dd40-ca18-4d34-acd5-5dd474595168" alt="pdfme Logo" width="333" height="163" />
</a>
</p>
Sponsorships help maintain and improve Gotenberg - [become a sponsor](https://github.com/sponsors/gulien) ❤️ **Historic & GitHub Sponsors**
--- - [TheCodingMachine](https://thecodingmachine.com/)
- [pdfme](https://pdfme.com/)
- [PDFBolt](https://pdfbolt.com)
- [FileToPDF.dev](https://filetopdf.dev)
<p align="center"> **Powered By**
<strong>Powered by</strong>
</p>
<p align="center"> - [Docker](https://docs.docker.com/docker-hub/repos/manage/trusted-content/dsos-program/)
<a href="https://jb.gg/OpenSource"> - [JetBrains](https://www.jetbrains.com/community/opensource/)
<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg" alt="JetBrains logo" width="200"/>
</a>
</p>

View File

@@ -2,40 +2,28 @@
## Supported Versions ## Supported Versions
Please ensure to keep your environment up to date and use only the latest version of Gotenberg. Only the latest version receives security updates and patches. Keep your environment up to date.
Security updates and patches will be applied only to the most recent version.
## Reporting a Vulnerability ## Reporting a Vulnerability
Your help in identifying vulnerabilities in our project is much appreciated. Do not publish vulnerabilities publicly. Report them through [GitHub's private vulnerability reporting](https://github.com/gotenberg/gotenberg/security/advisories/new).
We take all reports regarding security seriously.
If you discover a security vulnerability, please refrain from publishing it publicly. Include:
Instead, kindly send us the details via email to _neuhart [dot] julien [at] gmail [dot] com_.
In the subject of your email, please indicate that it's a security vulnerability report for Gotenberg.
In your message, please include:
- A detailed description of the vulnerability. - A detailed description of the vulnerability.
- The steps to reproduce the issue. - Steps to reproduce the issue.
- Any potential impact of the vulnerability on the users or system. - Potential impact on users or the system.
Please remember that this process is done in a _'best-effort'_ manner. This process is handled on a best-effort basis. Response speed may vary depending on severity and available resources.
This means we strive to respond and act as quickly as possible, but the speed may vary depending on the severity of
the issue and our resources.
Thank you in advance for helping to keep our project safe!
## Disclosure Policy ## Disclosure Policy
Once we have received your vulnerability report, we will work to validate and reproduce the issue. Once a report is received and confirmed:
If we can confirm the vulnerability, we will proceed to:
- Work on a fix and a release timeline. - A fix and release timeline will be prepared.
- Notify you when the fix has been implemented and released. - The reporter will be notified when the fix is released.
- Credit you for discovering the vulnerability (unless you request anonymity). - The reporter will be credited for the discovery (unless anonymity is requested).
- Please note that we will do our best to keep you informed about the progress towards resolving the issue.
## Comments on this Policy ## Comments on this Policy
If you have suggestions on how this process could be improved, please submit a pull request. Submit a pull request with suggestions for improving this process.

View File

@@ -1,7 +1,7 @@
# ARG instructions do not create additional layers. Instead, next layers will # ARG instructions do not create additional layers. Instead, next layers will
# concatenate them. Also, we have to repeat ARG instructions in each build # concatenate them. Also, we have to repeat ARG instructions in each build
# stage that uses them. # stage that uses them.
ARG GOLANG_VERSION=1.25.5 ARG GOLANG_VERSION=1.26.5
# ---------------------------------------------- # ----------------------------------------------
# pdfcpu binary build stage # pdfcpu binary build stage
@@ -11,22 +11,22 @@ ARG GOLANG_VERSION=1.25.5
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
# See https://github.com/pdfcpu/pdfcpu/releases. # See https://github.com/pdfcpu/pdfcpu/releases.
ARG PDFCPU_VERSION=v0.11.1 ARG PDFCPU_VERSION=v0.13.0
ENV CGO_ENABLED=0 ENV CGO_ENABLED=0
# Define the working directory outside of $GOPATH (we're using go modules). # Define the working directory outside of $GOPATH (we're using go modules).
WORKDIR /home WORKDIR /home
RUN curl -Ls "https://github.com/pdfcpu/pdfcpu/archive/refs/tags/$PDFCPU_VERSION.tar.gz" -o pdfcpu.tar.gz &&\ RUN curl -Ls "https://github.com/pdfcpu/pdfcpu/archive/refs/tags/$PDFCPU_VERSION.tar.gz" -o pdfcpu.tar.gz \
tar --strip-components=1 -xvzf pdfcpu.tar.gz && tar --strip-components=1 -xvzf pdfcpu.tar.gz
# Install module dependencies. # Install module dependencies.
RUN go mod download &&\ RUN go mod download \
go mod verify && go mod verify
RUN go build -o pdfcpu -ldflags "-s -w -X 'main.version=$PDFCPU_VERSION' -X 'github.com/pdfcpu/pdfcpu/pkg/pdfcpu.VersionStr=$PDFCPU_VERSION' -X main.builtBy=gotenberg" ./cmd/pdfcpu &&\ RUN go build -o pdfcpu -ldflags "-s -w -X 'main.version=$PDFCPU_VERSION' -X 'github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model.VersionStr=$PDFCPU_VERSION' -X main.builtBy=gotenberg" ./cmd/pdfcpu \
# Verify installation. # Verify installation.
./pdfcpu version && ./pdfcpu version
# ---------------------------------------------- # ----------------------------------------------
# Gotenberg binary build stage # Gotenberg binary build stage
@@ -42,14 +42,16 @@ WORKDIR /home
# Install module dependencies. # Install module dependencies.
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download &&\ RUN go mod download \
go mod verify && go mod verify
# Copy the source code. # Copy the source code.
COPY cmd ./cmd COPY cmd ./cmd
COPY pkg ./pkg COPY pkg ./pkg
RUN go build -o gotenberg -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go RUN go build -o gotenberg -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go \
&& go build -o gotenberg-chromium -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg-chromium/main.go \
&& go build -o gotenberg-libreoffice -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg-libreoffice/main.go
# ---------------------------------------------- # ----------------------------------------------
# Custom JRE stage # Custom JRE stage
@@ -57,10 +59,9 @@ RUN go build -o gotenberg -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/
# ---------------------------------------------- # ----------------------------------------------
FROM debian:13-slim AS custom-jre-stage FROM debian:13-slim AS custom-jre-stage
RUN \ RUN apt-get update -qq \
apt-get update -qq &&\ && apt-get upgrade -yqq \
apt-get upgrade -yqq &&\ && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends default-jdk-headless binutils
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends default-jdk-headless binutils
# Note: jdeps helps finding which modules a JAR requires. # Note: jdeps helps finding which modules a JAR requires.
# Currently only for PDFtk, as we don't rely on LibreOffice UNO Java SDK. # Currently only for PDFtk, as we don't rely on LibreOffice UNO Java SDK.
@@ -74,130 +75,107 @@ RUN jlink \
--compress=2 \ --compress=2 \
--output /custom-jre --output /custom-jre
# ----------------------------------------------
# Downloader stage
# ----------------------------------------------
FROM debian:13-slim AS downloader-stage
# See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
ARG PDFTK_VERSION=v3.3.3
RUN apt-get update -qq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl ca-certificates
WORKDIR /downloads
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.4.0/unoconv -o unoconverter \
&& chmod +x unoconverter
RUN curl -o pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" \
&& chmod a+x pdftk-all.jar
# ---------------------------------------------- # ----------------------------------------------
# Base image stage # Base image stage
# ---------------------------------------------- # ----------------------------------------------
FROM debian:13-slim AS base-image-stage FROM debian:13-slim AS base-image-stage
COPY --from=custom-jre-stage /custom-jre /opt/java ARG TIMEZONE=UTC
ENV TZ=$TIMEZONE
COPY --link --from=custom-jre-stage /custom-jre /opt/java
ENV PATH="/opt/java/bin:${PATH}" ENV PATH="/opt/java/bin:${PATH}"
# ---------------------------------------------- # ----------------------------------------------
# Final stage # Common stage
# ---------------------------------------------- # ----------------------------------------------
FROM base-image-stage FROM base-image-stage AS common-stage
ARG GOTENBERG_VERSION=snapshot
ARG GOTENBERG_USER_GID=1001 ARG GOTENBERG_USER_GID=1001
ARG GOTENBERG_USER_UID=1001 ARG GOTENBERG_USER_UID=1001
# See https://github.com/googlefonts/noto-emoji/releases.
ARG NOTO_COLOR_EMOJI_VERSION=v2.051
# See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
ARG PDFTK_VERSION=v3.3.3
LABEL org.opencontainers.image.title="Gotenberg" \ # Create a non-root user.
org.opencontainers.image.description="A containerized API for seamless PDF conversion." \ # All processes in the Docker container will run with this dedicated user.
org.opencontainers.image.version="$GOTENBERG_VERSION" \ RUN groupadd --gid "$GOTENBERG_USER_GID" gotenberg \
org.opencontainers.image.authors="Julien Neuhart <neuhart.julien@gmail.com>" \ && useradd --uid "$GOTENBERG_USER_UID" --gid gotenberg --shell /bin/bash --home /home/gotenberg --no-create-home gotenberg \
org.opencontainers.image.documentation="https://gotenberg.dev" \ && mkdir /home/gotenberg \
org.opencontainers.image.source="https://github.com/gotenberg/gotenberg" && chown gotenberg: /home/gotenberg
RUN \ # Note: tini is a helper for reaping zombie processes.
# Create a non-root user. RUN apt-get update -qq \
# All processes in the Docker container will run with this dedicated user. && apt-get upgrade -yqq \
groupadd --gid "$GOTENBERG_USER_GID" gotenberg &&\ && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
useradd --uid "$GOTENBERG_USER_UID" --gid gotenberg --shell /bin/bash --home /home/gotenberg --no-create-home gotenberg &&\ ca-certificates \
mkdir /home/gotenberg &&\ tini \
chown gotenberg: /home/gotenberg # Many users rely on curl for Docker health checks.
curl \
RUN \ # Install fonts.
# Install system dependencies required for the next instructions or debugging. # Essential metric-compatible fonts for LibreOffice layout fidelity (replaces MS Fonts).
# Note: tini is a helper for reaping zombie processes. fonts-crosextra-carlito \
apt-get update -qq &&\ fonts-crosextra-caladea \
apt-get upgrade -yqq &&\ fonts-liberation \
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl gnupg tini python3 python3-distutils-extra &&\ fonts-liberation2 \
# Reliable general-purpose fallback for Chromium.
fonts-dejavu \
# Unified CJK (Chinese, Japanese, Korean) support.
fonts-noto-cjk \
# Standard Emoji support.
fonts-noto-color-emoji \
# Tofu prevention.
fonts-noto-core \
# Install QPDF & ExifTool (PDF engines).
qpdf exiftool \
# See https://github.com/nextcloud/docker/issues/380.
&& mkdir -p /usr/share/man/man1 \
# Cleanup. # Cleanup.
# Note: the Debian image does automatically a clean after each install thanks to a hook. && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Therefore, there is no need for apt-get clean.
# See https://stackoverflow.com/a/24417119/3248473.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN \ # PDFtk bash wrapper.
# Install fonts. # See https://github.com/gotenberg/gotenberg/pull/273.
# Credits: RUN printf '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk \
# https://github.com/arachnys/athenapdf/blob/master/cli/Dockerfile. && chmod +x /usr/bin/pdftk
# https://help.accusoft.com/PrizmDoc/v12.1/HTML/Installing_Asian_Fonts_on_Ubuntu_and_Debian.html.
curl -o ./ttf-mscorefonts-installer_3.8.1_all.deb http://httpredir.debian.org/debian/pool/contrib/m/msttcorefonts/ttf-mscorefonts-installer_3.8.1_all.deb &&\
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
./ttf-mscorefonts-installer_3.8.1_all.deb \
culmus \
fonts-beng \
fonts-hosny-amiri \
fonts-lklug-sinhala \
fonts-lohit-guru \
fonts-lohit-knda \
fonts-samyak-gujr \
fonts-samyak-mlym \
fonts-samyak-taml \
fonts-sarai \
fonts-sil-abyssinica \
fonts-sil-padauk \
fonts-telu \
fonts-thai-tlwg \
ttf-wqy-zenhei \
fonts-arphic-ukai \
fonts-arphic-uming \
fonts-ipafont-mincho \
fonts-ipafont-gothic \
fonts-unfonts-core \
# LibreOffice recommends.
fonts-crosextra-caladea \
fonts-crosextra-carlito \
fonts-dejavu \
fonts-liberation \
fonts-liberation2 \
fonts-linuxlibertine \
fonts-noto-cjk \
fonts-noto-core \
fonts-noto-mono \
fonts-noto-ui-core \
fonts-sil-gentium \
fonts-sil-gentium-basic &&\
rm -f ./ttf-mscorefonts-installer_3.8.1_all.deb &&\
# Add Color and Black-and-White Noto emoji font.
# Credits:
# https://github.com/gotenberg/gotenberg/pull/325.
# https://github.com/googlefonts/noto-emoji.
curl -Ls "https://github.com/googlefonts/noto-emoji/raw/$NOTO_COLOR_EMOJI_VERSION/fonts/NotoColorEmoji.ttf" -o /usr/local/share/fonts/NotoColorEmoji.ttf &&\
# Cleanup.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN \ # Support for arbitrary user IDs (OpenShift).
# Install Hyphenation for LibreOffice. # See:
# Credits: https://wiki.archlinux.org/title/LibreOffice. # https://github.com/gotenberg/gotenberg/issues/1049.
apt-get update -qq &&\ # https://docs.redhat.com/en/documentation/openshift_container_platform/4.15/html/images/creating-images#use-uid_create-images.
apt-get upgrade -yqq &&\ RUN usermod -aG root gotenberg \
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \ && chgrp -R 0 /home/gotenberg \
hyphen-af hyphen-as hyphen-be hyphen-bg hyphen-bn hyphen-ca hyphen-cs hyphen-da hyphen-de hyphen-el \ && chmod -R g=u /home/gotenberg
hyphen-en-gb hyphen-en-us hyphen-eo hyphen-es hyphen-fr hyphen-gl hyphen-gu hyphen-hi hyphen-hr hyphen-hu \
hyphen-id hyphen-is hyphen-it hyphen-kn hyphen-lt hyphen-lv hyphen-ml hyphen-mn hyphen-mr hyphen-nl \
hyphen-no hyphen-or hyphen-pa hyphen-pl hyphen-pt-br hyphen-pt-pt hyphen-ro hyphen-ru hyphen-sk hyphen-sl \
hyphen-sr hyphen-sv hyphen-ta hyphen-te hyphen-th hyphen-uk hyphen-zu &&\
# Cleanup.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN \ # COPY instructions last to maximize cache reuse when only source code changes.
# Install Chromium.
apt-get update -qq &&\ # Improve fonts subpixel hinting and smoothing.
apt-get upgrade -yqq &&\ # Credits:
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium &&\ # https://github.com/arachnys/athenapdf/issues/69.
# Verify installation. # https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d.
chromium --version &&\ COPY --link build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf
# Cleanup.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* # Copy PDFtk jar.
COPY --link --from=downloader-stage /downloads/pdftk-all.jar /usr/bin/pdftk-all.jar
# Copy the pdfcpu binary (shared across all variants).
COPY --link --from=pdfcpu-binary-stage /home/pdfcpu /usr/bin/
# Set default characterset encoding to UTF-8. # Set default characterset encoding to UTF-8.
# See: # See:
@@ -206,74 +184,108 @@ RUN \
ENV LANG=C.UTF-8 ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8 ENV LC_ALL=C.UTF-8
RUN \ # Environment variables for PDF engines.
# Install LibreOffice & unoconverter. \
echo "deb http://deb.debian.org/debian trixie-backports main" >> /etc/apt/sources.list &&\
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports libreoffice &&\
curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.1.1/unoconv -o /usr/bin/unoconverter &&\
chmod +x /usr/bin/unoconverter &&\
# unoconverter will look for the Python binary, which has to be at version 3.
ln -s /usr/bin/python3 /usr/bin/python &&\
# Verify installations.
libreoffice --version &&\
unoconverter --version &&\
# Cleanup.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN \
# Install PDFtk, QPDF & ExifTool (PDF engines).
# See https://github.com/gotenberg/gotenberg/pull/273.
curl -o /usr/bin/pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" &&\
chmod a+x /usr/bin/pdftk-all.jar &&\
printf '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk && \
chmod +x /usr/bin/pdftk &&\
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf exiftool &&\
# See https://github.com/nextcloud/docker/issues/380.
mkdir -p /usr/share/man/man1 &&\
# Verify installations.
pdftk --version &&\
qpdf --version &&\
exiftool --version &&\
# Cleanup.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Support for arbitrary user IDs (OpenShift).
# See:
# https://github.com/gotenberg/gotenberg/issues/1049.
# https://docs.redhat.com/en/documentation/openshift_container_platform/4.15/html/images/creating-images#use-uid_create-images.
RUN \
usermod -aG root gotenberg &&\
chgrp -R 0 /home/gotenberg &&\
chmod -R g=u /home/gotenberg
# Improve fonts subpixel hinting and smoothing.
# Credits:
# https://github.com/arachnys/athenapdf/issues/69.
# https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d.
COPY build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf
# Copy dictionnaries so that hypens work on Chromium.
# See https://github.com/gotenberg/gotenberg/issues/1293.
COPY --chown=gotenberg:gotenberg build/chromium-hyphen-data /opt/gotenberg/chromium-hyphen-data
# Copy the Golang binaries.
COPY --from=pdfcpu-binary-stage /home/pdfcpu /usr/bin/
COPY --from=gotenberg-binary-stage /home/gotenberg /usr/bin/
# Environment variables required by modules or else.
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
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
ENV PDFTK_BIN_PATH=/usr/bin/pdftk ENV PDFTK_BIN_PATH=/usr/bin/pdftk
ENV QPDF_BIN_PATH=/usr/bin/qpdf ENV QPDF_BIN_PATH=/usr/bin/qpdf
ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool
ENV PDFCPU_BIN_PATH=/usr/bin/pdfcpu 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
ENV OTEL_LOGS_EXPORTER=none
# ----------------------------------------------
# Final stage: full (Chromium + LibreOffice)
# ----------------------------------------------
FROM common-stage AS gotenberg
ARG GOTENBERG_VERSION=snapshot
ARG GOTENBERG_USER_GID=1001
ARG GOTENBERG_USER_UID=1001
ARG TMP_CHROMIUM_VERSION_PPC64EL="146.0.7680.80-1~deb13u1"
LABEL org.opencontainers.image.title="Gotenberg" \
org.opencontainers.image.description="A Docker-based API for converting documents to PDF." \
org.opencontainers.image.version="$GOTENBERG_VERSION" \
org.opencontainers.image.authors="Julien Neuhart <neuhart.julien@gmail.com>" \
org.opencontainers.image.documentation="https://gotenberg.dev" \
org.opencontainers.image.source="https://github.com/gotenberg/gotenberg"
# Install Chromium.
# On ppc64el, the latest Chromium is broken, so we pin a known working
# version from snapshot.debian.org via debsnap.
RUN /bin/bash -c \
'set -e &&\
if [[ "$(dpkg --print-architecture)" == "ppc64el" ]]; then \
apt-get update -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends devscripts &&\
debsnap chromium-common "$TMP_CHROMIUM_VERSION_PPC64EL" -v --force --binary --architecture ppc64el &&\
debsnap chromium "$TMP_CHROMIUM_VERSION_PPC64EL" -v --force --binary --architecture ppc64el &&\
DEBIAN_FRONTEND=noninteractive apt-get install --fix-broken -y -qq --no-install-recommends "./binary-chromium-common/chromium-common_${TMP_CHROMIUM_VERSION_PPC64EL}_ppc64el.deb" "./binary-chromium/chromium_${TMP_CHROMIUM_VERSION_PPC64EL}_ppc64el.deb" &&\
DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq devscripts &&\
rm -rf ./binary-chromium-common/* ./binary-chromium/*; \
else \
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \
fi' \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Install LibreOffice & unoconverter.
RUN echo "deb http://deb.debian.org/debian trixie-backports main" >> /etc/apt/sources.list \
&& apt-get update -qq \
&& apt-get upgrade -yqq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
python3 python3-distutils-extra \
# Install Hyphenation for LibreOffice.
# Credits: https://wiki.archlinux.org/title/LibreOffice.
hyphen-af hyphen-as hyphen-be hyphen-bg hyphen-bn hyphen-ca hyphen-cs hyphen-da hyphen-de hyphen-el \
hyphen-en-gb hyphen-en-us hyphen-eo hyphen-es hyphen-fr hyphen-gl hyphen-gu hyphen-hi hyphen-hr hyphen-hu \
hyphen-id hyphen-is hyphen-it hyphen-kn hyphen-lt hyphen-lv hyphen-ml hyphen-mn hyphen-mr hyphen-nl \
hyphen-no hyphen-or hyphen-pa hyphen-pl hyphen-pt-br hyphen-pt-pt hyphen-ro hyphen-ru hyphen-sk hyphen-sl \
hyphen-sr hyphen-sv hyphen-ta hyphen-te hyphen-th hyphen-uk hyphen-zu \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports \
libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw python3-uno \
# unoconverter will look for the Python binary, which has to be at version 3.
&& ln -s /usr/bin/python3 /usr/bin/python \
# Cleanup.
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# COPY instructions last to maximize cache reuse.
# Copy the Gotenberg binary (full variant with all modules).
COPY --link --from=gotenberg-binary-stage /home/gotenberg /usr/bin/
# Copy unoconverter.
COPY --link --from=downloader-stage /downloads/unoconverter /usr/bin/unoconverter
# Copy dictionnaries so that hyphens work on Chromium.
# See https://github.com/gotenberg/gotenberg/issues/1293.
COPY --link --chown="$GOTENBERG_USER_UID:$GOTENBERG_USER_GID" build/chromium-hyphen-data /opt/gotenberg/chromium-hyphen-data
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
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 USER gotenberg
WORKDIR /home/gotenberg WORKDIR /home/gotenberg
@@ -282,3 +294,240 @@ EXPOSE 3000
ENTRYPOINT [ "/usr/bin/tini", "--" ] ENTRYPOINT [ "/usr/bin/tini", "--" ]
CMD [ "gotenberg" ] CMD [ "gotenberg" ]
# ----------------------------------------------
# Final stage: Chromium only
# ----------------------------------------------
FROM common-stage AS gotenberg-chromium
ARG GOTENBERG_VERSION=snapshot
ARG GOTENBERG_USER_GID=1001
ARG GOTENBERG_USER_UID=1001
ARG TMP_CHROMIUM_VERSION_PPC64EL="146.0.7680.80-1~deb13u1"
LABEL org.opencontainers.image.title="Gotenberg (Chromium)" \
org.opencontainers.image.description="A Docker-based API for converting documents to PDF — Chromium variant." \
org.opencontainers.image.version="$GOTENBERG_VERSION" \
org.opencontainers.image.authors="Julien Neuhart <neuhart.julien@gmail.com>" \
org.opencontainers.image.documentation="https://gotenberg.dev" \
org.opencontainers.image.source="https://github.com/gotenberg/gotenberg"
# Install Chromium.
# On ppc64el, the latest Chromium is broken, so we pin a known working
# version from snapshot.debian.org via debsnap.
RUN /bin/bash -c \
'set -e &&\
if [[ "$(dpkg --print-architecture)" == "ppc64el" ]]; then \
apt-get update -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends devscripts &&\
debsnap chromium-common "$TMP_CHROMIUM_VERSION_PPC64EL" -v --force --binary --architecture ppc64el &&\
debsnap chromium "$TMP_CHROMIUM_VERSION_PPC64EL" -v --force --binary --architecture ppc64el &&\
DEBIAN_FRONTEND=noninteractive apt-get install --fix-broken -y -qq --no-install-recommends "./binary-chromium-common/chromium-common_${TMP_CHROMIUM_VERSION_PPC64EL}_ppc64el.deb" "./binary-chromium/chromium_${TMP_CHROMIUM_VERSION_PPC64EL}_ppc64el.deb" &&\
DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq devscripts &&\
rm -rf ./binary-chromium-common/* ./binary-chromium/*; \
else \
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \
fi' \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# COPY instructions last to maximize cache reuse.
# Copy the Gotenberg binary (Chromium variant, no LibreOffice modules).
COPY --link --from=gotenberg-binary-stage /home/gotenberg-chromium /usr/bin/gotenberg
# Copy dictionnaries so that hyphens work on Chromium.
# See https://github.com/gotenberg/gotenberg/issues/1293.
COPY --link --chown="$GOTENBERG_USER_UID:$GOTENBERG_USER_GID" build/chromium-hyphen-data /opt/gotenberg/chromium-hyphen-data
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
ENV CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data
# No LibreOffice in this variant; override the default to use all available engines.
ENV PDFENGINES_CONVERT_ENGINES=
# Capture the Chromium version now that it is installed.
RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" chromium "$CHROMIUM_BIN_PATH" --version
USER gotenberg
WORKDIR /home/gotenberg
# Default API port.
EXPOSE 3000
ENTRYPOINT [ "/usr/bin/tini", "--" ]
CMD [ "gotenberg" ]
# ----------------------------------------------
# Final stage: LibreOffice only
# ----------------------------------------------
FROM common-stage AS gotenberg-libreoffice
ARG GOTENBERG_VERSION=snapshot
LABEL org.opencontainers.image.title="Gotenberg (LibreOffice)" \
org.opencontainers.image.description="A Docker-based API for converting documents to PDF — LibreOffice variant." \
org.opencontainers.image.version="$GOTENBERG_VERSION" \
org.opencontainers.image.authors="Julien Neuhart <neuhart.julien@gmail.com>" \
org.opencontainers.image.documentation="https://gotenberg.dev" \
org.opencontainers.image.source="https://github.com/gotenberg/gotenberg"
# Install LibreOffice & unoconverter.
RUN echo "deb http://deb.debian.org/debian trixie-backports main" >> /etc/apt/sources.list \
&& apt-get update -qq \
&& apt-get upgrade -yqq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
python3 python3-distutils-extra \
# Install Hyphenation for LibreOffice.
# Credits: https://wiki.archlinux.org/title/LibreOffice.
hyphen-af hyphen-as hyphen-be hyphen-bg hyphen-bn hyphen-ca hyphen-cs hyphen-da hyphen-de hyphen-el \
hyphen-en-gb hyphen-en-us hyphen-eo hyphen-es hyphen-fr hyphen-gl hyphen-gu hyphen-hi hyphen-hr hyphen-hu \
hyphen-id hyphen-is hyphen-it hyphen-kn hyphen-lt hyphen-lv hyphen-ml hyphen-mn hyphen-mr hyphen-nl \
hyphen-no hyphen-or hyphen-pa hyphen-pl hyphen-pt-br hyphen-pt-pt hyphen-ro hyphen-ru hyphen-sk hyphen-sl \
hyphen-sr hyphen-sv hyphen-ta hyphen-te hyphen-th hyphen-uk hyphen-zu \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports \
libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw python3-uno \
# unoconverter will look for the Python binary, which has to be at version 3.
&& ln -s /usr/bin/python3 /usr/bin/python \
# Cleanup.
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# COPY instructions last to maximize cache reuse.
# Copy the Gotenberg binary (LibreOffice variant, no Chromium modules).
COPY --link --from=gotenberg-binary-stage /home/gotenberg-libreoffice /usr/bin/gotenberg
# Copy unoconverter.
COPY --link --from=downloader-stage /downloads/unoconverter /usr/bin/unoconverter
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
# Default API port.
EXPOSE 3000
ENTRYPOINT [ "/usr/bin/tini", "--" ]
CMD [ "gotenberg" ]
# ==============================================================
# Cloud Run variants
# ==============================================================
# ----------------------------------------------
# Cloud Run: full (Chromium + LibreOffice)
# ----------------------------------------------
FROM gotenberg AS gotenberg-cloudrun
USER root
# For security reasons, the non-root user gotenberg does not own the Tini binary by default.
# However, some providers like Cloud Run from Google Cloud cannot start a Docker container in that case.
# See https://github.com/gotenberg/gotenberg/issues/90#issuecomment-543551353.
RUN chown gotenberg: /usr/bin/tini
ENV API_PORT_FROM_ENV=PORT
ENV CHROMIUM_AUTO_START=true
ENV LIBREOFFICE_AUTO_START=true
ENV WEBHOOK_ENABLE_SYNC_MODE=true
ENV GOTENBERG_BUILD_DEBUG_DATA=false
ENV LOG_STD_ENABLE_GCP_FIELDS=true
USER gotenberg
# ----------------------------------------------
# Cloud Run: Chromium only
# ----------------------------------------------
FROM gotenberg-chromium AS gotenberg-cloudrun-chromium
USER root
RUN chown gotenberg: /usr/bin/tini
ENV API_PORT_FROM_ENV=PORT
ENV CHROMIUM_AUTO_START=true
ENV WEBHOOK_ENABLE_SYNC_MODE=true
ENV GOTENBERG_BUILD_DEBUG_DATA=false
ENV LOG_STD_ENABLE_GCP_FIELDS=true
USER gotenberg
# ----------------------------------------------
# Cloud Run: LibreOffice only
# ----------------------------------------------
FROM gotenberg-libreoffice AS gotenberg-cloudrun-libreoffice
USER root
RUN chown gotenberg: /usr/bin/tini
ENV API_PORT_FROM_ENV=PORT
ENV LIBREOFFICE_AUTO_START=true
ENV WEBHOOK_ENABLE_SYNC_MODE=true
ENV GOTENBERG_BUILD_DEBUG_DATA=false
ENV LOG_STD_ENABLE_GCP_FIELDS=true
USER gotenberg
# ==============================================================
# AWS Lambda variants
# ==============================================================
# ----------------------------------------------
# AWS Lambda: full (Chromium + LibreOffice)
# ----------------------------------------------
FROM gotenberg AS gotenberg-aws-lambda
USER root
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
ENV AWS_LWA_PORT=3000
ENV AWS_LWA_READINESS_CHECK_PATH=/health
ENV AWS_LWA_INVOKE_MODE=buffered
ENV API_PORT_FROM_ENV=AWS_LWA_PORT
ENV WEBHOOK_ENABLE_SYNC_MODE=true
ENV GOTENBERG_BUILD_DEBUG_DATA=false
USER gotenberg
# ----------------------------------------------
# AWS Lambda: Chromium only
# ----------------------------------------------
FROM gotenberg-chromium AS gotenberg-aws-lambda-chromium
USER root
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
ENV AWS_LWA_PORT=3000
ENV AWS_LWA_READINESS_CHECK_PATH=/health
ENV AWS_LWA_INVOKE_MODE=buffered
ENV API_PORT_FROM_ENV=AWS_LWA_PORT
ENV WEBHOOK_ENABLE_SYNC_MODE=true
ENV GOTENBERG_BUILD_DEBUG_DATA=false
USER gotenberg
# ----------------------------------------------
# AWS Lambda: LibreOffice only
# ----------------------------------------------
FROM gotenberg-libreoffice AS gotenberg-aws-lambda-libreoffice
USER root
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
ENV AWS_LWA_PORT=3000
ENV AWS_LWA_READINESS_CHECK_PATH=/health
ENV AWS_LWA_INVOKE_MODE=buffered
ENV API_PORT_FROM_ENV=AWS_LWA_PORT
ENV WEBHOOK_ENABLE_SYNC_MODE=true
ENV GOTENBERG_BUILD_DEBUG_DATA=false
USER gotenberg

View File

@@ -1,21 +0,0 @@
ARG DOCKER_REGISTRY
ARG DOCKER_REPOSITORY
ARG GOTENBERG_VERSION
FROM $DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION
USER root
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
# AWS.
ENV AWS_LWA_PORT=3000
ENV AWS_LWA_READINESS_CHECK_PATH=/health
ENV AWS_LWA_INVOKE_MODE=buffered
# Gotenberg.
ENV API_PORT_FROM_ENV=AWS_LWA_PORT
ENV WEBHOOK_ENABLE_SYNC_MODE=true
ENV GOTENBERG_BUILD_DEBUG_DATA=false
USER gotenberg

View File

@@ -1,22 +0,0 @@
ARG DOCKER_REGISTRY
ARG DOCKER_REPOSITORY
ARG GOTENBERG_VERSION
FROM $DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION
USER root
# For security reasons, the non-root user gotenberg does not own the Tini binary by default.
# However, some providers like Cloud Run from Google Cloud cannot start a Docker container in that case.
# See https://github.com/gotenberg/gotenberg/issues/90#issuecomment-543551353.
RUN chown gotenberg: /usr/bin/tini
# Gotenberg.
ENV API_PORT_FROM_ENV=PORT
ENV CHROMIUM_AUTO_START=true
ENV LIBREOFFICE_AUTO_START=true
ENV WEBHOOK_ENABLE_SYNC_MODE=true
ENV GOTENBERG_BUILD_DEBUG_DATA=false
ENV LOG_ENABLE_GCP_FIELDS=true
USER 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

@@ -0,0 +1,11 @@
package main
import (
gotenbergcmd "github.com/gotenberg/gotenberg/v8/cmd"
// Gotenberg modules (Chromium variant — no LibreOffice).
_ "github.com/gotenberg/gotenberg/v8/pkg/standard/chromium"
)
func main() {
gotenbergcmd.Run()
}

View File

@@ -0,0 +1,11 @@
package main
import (
gotenbergcmd "github.com/gotenberg/gotenberg/v8/cmd"
// Gotenberg modules (LibreOffice variant — no Chromium).
_ "github.com/gotenberg/gotenberg/v8/pkg/standard/libreoffice"
)
func main() {
gotenbergcmd.Run()
}

View File

@@ -25,7 +25,7 @@ const banner = `
\___/\___/\__/\__/_//_/_.__/\__/_/ \_, / \___/\___/\__/\__/_//_/_.__/\__/_/ \_, /
/___/ /___/
A containerized API for seamless PDF conversion. A Docker-based API for converting documents to PDF.
Version: %s Version: %s
------------------------------------------------------- -------------------------------------------------------
` `
@@ -44,11 +44,30 @@ func Run() {
fs.Duration("gotenberg-graceful-shutdown-duration", time.Duration(30)*time.Second, "Set the graceful shutdown duration") fs.Duration("gotenberg-graceful-shutdown-duration", time.Duration(30)*time.Second, "Set the graceful shutdown duration")
fs.Bool("gotenberg-build-debug-data", true, "Set if build data is needed") fs.Bool("gotenberg-build-debug-data", true, "Set if build data is needed")
// Logging & telemetry flags.
fs.String("log-level", gotenberg.InfoLoggingLevel, "Set the log level")
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")
fs.Bool("log-enable-gcp-fields", false, "Use GCP-compatible field names")
if err := errors.Join(
fs.MarkDeprecated("log-format", "use --log-std-format instead"),
fs.MarkDeprecated("log-enable-gcp-fields", "use --log-std-enable-gcp-fields instead"),
); err != nil {
fmt.Printf("[FATAL] mark deprecated flags: %s\n", err)
os.Exit(1)
}
descriptors := gotenberg.GetModuleDescriptors() descriptors := gotenberg.GetModuleDescriptors()
var modsInfo string var modsInfo strings.Builder
for _, desc := range descriptors { for _, desc := range descriptors {
fs.AddFlagSet(desc.FlagSet) fs.AddFlagSet(desc.FlagSet)
modsInfo += desc.ID + " " modsInfo.WriteString(desc.ID + " ")
} }
// Parse the flags. // Parse the flags.
@@ -76,10 +95,11 @@ func Run() {
fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err) fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err)
os.Exit(1) os.Exit(1)
} }
f.Changed = true
return return
} }
err = f.Value.Set(val) err = fs.Set(f.Name, val)
if err != nil { if err != nil {
fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err) fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err)
os.Exit(1) os.Exit(1)
@@ -91,10 +111,40 @@ func Run() {
hideBanner := parsedFlags.MustBool("gotenberg-hide-banner") hideBanner := parsedFlags.MustBool("gotenberg-hide-banner")
gracefulShutdownDuration := parsedFlags.MustDuration("gotenberg-graceful-shutdown-duration") gracefulShutdownDuration := parsedFlags.MustDuration("gotenberg-graceful-shutdown-duration")
// Initialize telemetry (logging + OTEL).
serviceName := os.Getenv("OTEL_SERVICE_NAME")
if serviceName == "" {
serviceName = "gotenberg"
}
telemetryCfg := gotenberg.TelemetryConfig{
ServiceName: serviceName,
ServiceVersion: Version,
LogLevel: parsedFlags.MustDeprecatedString("log-format", "log-std-format"),
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")
err = telemetryCfg.Validate()
if err != nil {
fmt.Printf("[FATAL] invalid telemetry config: %s\n", err)
os.Exit(1)
}
shutdownTelemetry, err := gotenberg.StartTelemetry(telemetryCfg)
if err != nil {
fmt.Printf("[FATAL] start telemetry: %s\n", err)
os.Exit(1)
}
if !hideBanner { if !hideBanner {
fmt.Printf(banner, Version) fmt.Printf(banner, Version)
} }
fmt.Printf("[SYSTEM] modules: %s\n", modsInfo) fmt.Printf("[SYSTEM] modules: %s\n", modsInfo.String())
ctx := gotenberg.NewContext(parsedFlags, descriptors) ctx := gotenberg.NewContext(parsedFlags, descriptors)
@@ -190,8 +240,17 @@ func Run() {
err = eg.Wait() err = eg.Wait()
if err != nil { if err != nil {
cancel()
fmt.Printf("[FATAL] %v\n", err) fmt.Printf("[FATAL] %v\n", err)
os.Exit(1) os.Exit(1) //nolint:gocritic // defers are already called explicitly above
}
// Shutdown telemetry (flush spans, metrics, logs).
err = shutdownTelemetry(gracefulShutdownCtx)
if err != nil {
cancel()
fmt.Printf("[FATAL] %v\n", err)
os.Exit(1) //nolint:gocritic // defers are already called explicitly above
} }
os.Exit(0) os.Exit(0)

138
compose.yaml Normal file
View File

@@ -0,0 +1,138 @@
services:
gotenberg:
image: ${DOCKER_REGISTRY}/${DOCKER_REPOSITORY}:${GOTENBERG_VERSION}
ports:
- "${API_PORT}:${API_PORT}"
environment:
GOTENBERG_API_BASIC_AUTH_USERNAME: ${GOTENBERG_API_BASIC_AUTH_USERNAME}
GOTENBERG_API_BASIC_AUTH_PASSWORD: ${GOTENBERG_API_BASIC_AUTH_PASSWORD}
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME}
OTEL_TRACES_EXPORTER: ${OTEL_TRACES_EXPORTER}
OTEL_METRICS_EXPORTER: ${OTEL_METRICS_EXPORTER}
OTEL_LOGS_EXPORTER: ${OTEL_LOGS_EXPORTER}
OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT}
OTEL_EXPORTER_OTLP_INSECURE: ${OTEL_EXPORTER_OTLP_INSECURE}
command:
- "gotenberg"
- "--gotenberg-hide-banner=${GOTENBERG_HIDE_BANNER}"
- "--gotenberg-graceful-shutdown-duration=${GOTENBERG_GRACEFUL_SHUTDOWN_DURATION}"
- "--gotenberg-build-debug-data=${GOTENBERG_BUILD_DEBUG_DATA}"
- "--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}"
- "--api-root-path=${API_ROOT_PATH}"
- "--api-correlation-id-header=${API_CORRELATION_ID_HEADER}"
- "--api-enable-basic-auth=${API_ENABLE_BASIC_AUTH}"
- "--api-download-from-allow-list=${API_DOWNLOAD_FROM_ALLOW_LIST}"
- "--api-download-from-deny-list=${API_DOWNLOAD_FROM_DENY_LIST}"
- "--api-download-from-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}"
- "--api-disable-root-route-telemetry=${API_DISABLE_ROOT_ROUTE_TELEMETRY}"
- "--api-disable-debug-route-telemetry=${API_DISABLE_DEBUG_ROUTE_TELEMETRY}"
- "--api-disable-version-route-telemetry=${API_DISABLE_VERSION_ROUTE_TELEMETRY}"
- "--api-enable-debug-route=${API_ENABLE_DEBUG_ROUTE}"
- "--chromium-restart-after=${CHROMIUM_RESTART_AFTER}"
- "--chromium-auto-start=${CHROMIUM_AUTO_START}"
- "--chromium-max-queue-size=${CHROMIUM_MAX_QUEUE_SIZE}"
- "--chromium-idle-shutdown-timeout=${CHROMIUM_IDLE_SHUTDOWN_TIMEOUT}"
- "--chromium-max-concurrency=${CHROMIUM_MAX_CONCURRENCY}"
- "--chromium-start-timeout=${CHROMIUM_START_TIMEOUT}"
- "--chromium-allow-insecure-localhost=${CHROMIUM_ALLOW_INSECURE_LOCALHOST}"
- "--chromium-ignore-certificate-errors=${CHROMIUM_IGNORE_CERTIFICATE_ERRORS}"
- "--chromium-disable-web-security=${CHROMIUM_DISABLE_WEB_SECURITY}"
- "--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}"
- "--chromium-disable-routes=${CHROMIUM_DISABLE_ROUTES}"
- "--libreoffice-restart-after=${LIBREOFFICE_RESTART_AFTER}"
- "--libreoffice-max-queue-size=${LIBREOFFICE_MAX_QUEUE_SIZE}"
- "--libreoffice-idle-shutdown-timeout=${LIBREOFFICE_IDLE_SHUTDOWN_TIMEOUT}"
- "--libreoffice-auto-start=${LIBREOFFICE_AUTO_START}"
- "--libreoffice-start-timeout=${LIBREOFFICE_START_TIMEOUT}"
- "--libreoffice-allow-list=${LIBREOFFICE_ALLOW_LIST}"
- "--libreoffice-deny-list=${LIBREOFFICE_DENY_LIST}"
- "--libreoffice-deny-private-ips=${LIBREOFFICE_DENY_PRIVATE_IPS}"
- "--libreoffice-deny-public-ips=${LIBREOFFICE_DENY_PUBLIC_IPS}"
- "--libreoffice-enable-environment-proxy=${LIBREOFFICE_ENABLE_ENVIRONMENT_PROXY}"
- "--libreoffice-disable-routes=${LIBREOFFICE_DISABLE_ROUTES}"
- "--log-level=${LOG_LEVEL}"
- "--log-fields-prefix=${LOG_FIELDS_PREFIX}"
- "--log-std-format=${LOG_STD_FORMAT}"
- "--log-std-enable-gcp-fields=${LOG_STD_ENABLE_GCP_FIELDS}"
- "--log-std-level-case=${LOG_STD_LEVEL_CASE}"
- "--pdfengines-merge-engines=${PDFENGINES_MERGE_ENGINES}"
- "--pdfengines-split-engines=${PDFENGINES_SPLIT_ENGINES}"
- "--pdfengines-flatten-engines=${PDFENGINES_FLATTEN_ENGINES}"
- "--pdfengines-convert-engines=${PDFENGINES_CONVERT_ENGINES}"
- "--pdfengines-read-metadata-engines=${PDFENGINES_READ_METADATA_ENGINES}"
- "--pdfengines-write-metadata-engines=${PDFENGINES_WRITE_METADATA_ENGINES}"
- "--pdfengines-read-bookmarks-engines=${PDFENGINES_READ_BOOKMARKS_ENGINES}"
- "--pdfengines-write-bookmarks-engines=${PDFENGINES_WRITE_BOOKMARKS_ENGINES}"
- "--pdfengines-watermark-engines=${PDFENGINES_WATERMARK_ENGINES}"
- "--pdfengines-stamp-engines=${PDFENGINES_STAMP_ENGINES}"
- "--pdfengines-encrypt-engines=${PDFENGINES_ENCRYPT_ENGINES}"
- "--pdfengines-rotate-engines=${PDFENGINES_ROTATE_ENGINES}"
- "--pdfengines-embed-engines=${PDFENGINES_EMBED_ENGINES}"
- "--pdfengines-embed-metadata-engines=${PDFENGINES_EMBED_METADATA_ENGINES}"
- "--pdfengines-factur-x-engines=${PDFENGINES_FACTUR_X_ENGINES}"
- "--pdfengines-disable-routes=${PDFENGINES_DISABLE_ROUTES}"
- "--prometheus-namespace=${PROMETHEUS_NAMESPACE}"
- "--prometheus-collect-interval=${PROMETHEUS_COLLECT_INTERVAL}"
- "--prometheus-disable-route-telemetry=${PROMETHEUS_DISABLE_ROUTE_TELEMETRY}"
- "--prometheus-disable-collect=${PROMETHEUS_DISABLE_COLLECT}"
- "--prometheus-metrics-path=${PROMETHEUS_METRICS_PATH}"
- "--webhook-enable-sync-mode=${WEBHOOK_ENABLE_SYNC_MODE}"
- "--webhook-allow-list=${WEBHOOK_ALLOW_LIST}"
- "--webhook-deny-list=${WEBHOOK_DENY_LIST}"
- "--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}"
- "--webhook-client-timeout=${WEBHOOK_CLIENT_TIMEOUT}"
- "--webhook-disable=${WEBHOOK_DISABLE}"
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
command: ["--config=/etc/otel-collector-config.yaml"]
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
ports:
- "4317:4317" # OTLP gRPC receiver
depends_on:
openobserve:
condition: service_started
restart: on-failure
openobserve:
image: public.ecr.aws/zinclabs/openobserve:latest
restart: always
ports:
- "5080:5080"
- "5081:5081"
environment:
ZO_ROOT_USER_EMAIL: telemetry@gotenberg.dev
ZO_ROOT_USER_PASSWORD: telemetry
networks:
default:
enable_ipv6: false

128
go.mod
View File

@@ -1,33 +1,40 @@
module github.com/gotenberg/gotenberg/v8 module github.com/gotenberg/gotenberg/v8
go 1.25.5 go 1.26.5
require ( require (
github.com/alexliesenfeld/health v0.8.1 github.com/alexliesenfeld/health v0.8.1
github.com/barasher/go-exiftool v1.10.0 github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d // pinned with chromedp v0.14.2, see below
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d 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/chromedp/chromedp v0.14.2 github.com/cucumber/godog v0.16.0
github.com/cucumber/godog v0.15.1 github.com/dlclark/regexp2 v1.12.0
github.com/dlclark/regexp2 v1.11.5 github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76
github.com/docker/docker v28.5.2+incompatible
github.com/docker/go-connections v0.6.0
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/hashicorp/go-retryablehttp v0.7.8 github.com/hashicorp/go-retryablehttp v0.7.8
github.com/labstack/echo/v4 v4.15.0 github.com/labstack/echo/v4 v4.15.4
github.com/labstack/gommon v0.4.2 github.com/labstack/gommon v0.5.0
github.com/mholt/archives v0.1.5 github.com/mholt/archives v0.1.5
github.com/microcosm-cc/bluemonday v1.0.27 github.com/microcosm-cc/bluemonday v1.0.27
github.com/prometheus/client_golang v1.23.2 github.com/moby/moby/api v1.55.0
github.com/shirou/gopsutil/v4 v4.25.12 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/spf13/pflag v1.0.10
github.com/testcontainers/testcontainers-go v0.40.0 github.com/stretchr/testify v1.11.1
go.uber.org/multierr v1.11.0 github.com/testcontainers/testcontainers-go v0.43.0
go.uber.org/zap v1.27.1 go.opentelemetry.io/contrib/bridges/otelslog v0.19.0
golang.org/x/net v0.49.0 go.opentelemetry.io/contrib/exporters/autoexport v0.69.0
golang.org/x/sync v0.19.0 go.opentelemetry.io/otel v1.45.0
golang.org/x/term v0.39.0 go.opentelemetry.io/otel/log v0.20.0
golang.org/x/text v0.33.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 ( require (
@@ -35,13 +42,14 @@ require (
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/STARRY-S/zip v0.2.3 // indirect github.com/STARRY-S/zip v0.2.3 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.2.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bodgit/plumbing v1.3.0 // 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/bodgit/windows v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect github.com/chromedp/sysutil v1.1.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect
@@ -49,77 +57,87 @@ require (
github.com/containerd/log v0.1.0 // indirect github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect github.com/cucumber/gherkin/go/v42 v42.0.0 // indirect
github.com/cucumber/messages/go/v21 v21.0.1 // indirect github.com/cucumber/messages/go/v34 v34.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/ebitengine/purego v0.9.1 // indirect github.com/ebitengine/purego v0.10.2 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // 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/gorilla/css v1.0.1 // 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-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-memdb v1.3.5 // indirect github.com/hashicorp/go-memdb v1.3.5 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/klauspost/compress v1.18.3 // indirect github.com/klauspost/compress v1.19.1 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect github.com/klauspost/pgzip v1.2.6 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/magiconair/properties v1.8.10 // indirect github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect
github.com/minio/minlz v1.0.1 // indirect github.com/minio/minlz v1.1.1 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/patternmatcher v0.6.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/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.1.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/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/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pierrec/lz4/v4 v4.1.25 // indirect github.com/pierrec/lz4/v4 v4.1.27 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.2 // 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/procfs v0.19.2 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sorairolake/lzip-go v0.3.8 // indirect github.com/sorairolake/lzip-go v0.3.8 // indirect
github.com/spf13/afero v1.15.0 // indirect github.com/spf13/afero v1.15.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect github.com/stangelandcl/ppmd v0.1.1 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect github.com/tklauser/numcpus v0.12.0 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect github.com/ulikunitz/xz v0.5.15 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect go.opentelemetry.io/contrib/bridges/prometheus v0.69.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // 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
go4.org v0.0.0-20260112195520-a5071408f32f // indirect go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/crypto v0.47.0 // indirect golang.org/x/crypto v0.54.0 // indirect
golang.org/x/sys v0.40.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.15.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 google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

293
go.sum
View File

@@ -10,22 +10,22 @@ github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk= github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
github.com/alexliesenfeld/health v0.8.1 h1:wdE3vt+cbJotiR8DGDBZPKHDFoJbAoWEfQTcqrmedUg= github.com/alexliesenfeld/health v0.8.1 h1:wdE3vt+cbJotiR8DGDBZPKHDFoJbAoWEfQTcqrmedUg=
github.com/alexliesenfeld/health v0.8.1/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc= github.com/alexliesenfeld/health v0.8.1/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEXNMpIhs=
github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 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 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= 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.4 h1:iHiVJfxbrB6RF4X+snI2MpVgNBKmVfGaTqZGNlMQIU0=
github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8= 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 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU= github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU=
@@ -44,43 +44,39 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/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 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= 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.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/cucumber/gherkin/go/v42 v42.0.0 h1:Ulh3E2awUUSSja+wonP/IOQ+ycmiZwZbgmzqk5H8JNI=
github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI= github.com/cucumber/gherkin/go/v42 v42.0.0/go.mod h1:CsaumaO2dR9XvBc6ZyiGLMhWCKtTRDxgoxqJigSjSSg=
github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0= github.com/cucumber/godog v0.16.0 h1:ezQbgItuWqZrjPUQwLJ3muwIlvzXBOfZso5QZfG7efE=
github.com/cucumber/godog v0.15.1 h1:rb/6oHDdvVZKS66hrhpjFQFHjthFSrQBCOI1LwshNTI= github.com/cucumber/godog v0.16.0/go.mod h1:EDUX9yCqANK+GpbftMDeu61sUDtdLuo1JJgXD2n3bbM=
github.com/cucumber/godog v0.15.1/go.mod h1:qju+SQDewOljHuq9NSM66s0xEhogx0q30flfxL4WUk8= github.com/cucumber/messages/go/v34 v34.2.0 h1:VCbcNOMz+f8ccjjOOx1NLBNhwvE7/X49Atc8klJa+i8=
github.com/cucumber/messages/go/v21 v21.0.1 h1:wzA0LxwjlWQYZd32VTlAVDTkW6inOFmSM+RuOwHZiMI= github.com/cucumber/messages/go/v34 v34.2.0/go.mod h1:LYUPjqlTS1kS0pdkdf6sS5uirnjwiIzEGyXPezXNhL8=
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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
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-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= 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/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 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= 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/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= 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 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6 h1:nxP4pPoyqOAgX8lYDFCfl3DyKeXErCvSvhcyzwGV9CE=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= 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.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.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 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 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 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= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
@@ -92,12 +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/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 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= 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/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76 h1:Ltt9ldIaSYEsjA7sPY2c8r9dOmnKM1vlzhh3dxlhBHM=
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A=
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
@@ -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/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 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= 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 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 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 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 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 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= 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 h1:b3taDMxCBCBVgyRrS1AZVHO14ubMYZB++QpNhBg+Nyo=
github.com/hashicorp/go-memdb v1.3.5/go.mod h1:8IVKKBkVe+fxFgdFOYxzQQNjz+sWCyHCdIC/+5+Vy1Y= 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= github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
@@ -128,122 +120,114 @@ github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iP
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru 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 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= 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/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24= github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs=
github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= github.com/labstack/echo/v4 v4.15.4/go.mod h1:CuMetKIRwsuO/qlAgMq+KTAalwGoB/h4tC+yPdrTj1g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= 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 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4= 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 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 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 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0=
github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc= github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A= github.com/minio/minlz v1.1.1 h1:OGmft1V6AnI/Wme332U6bhG54nxEan+VFgkD7lat4KM=
github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= 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 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= 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 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
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 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 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.5 h1:L5doqgGfQwI7qADJMqnkrSB86rpPsqQDrHeO0HWa5JY=
github.com/nwaples/rardecode/v2 v2.2.2/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= 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 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 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 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 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 h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/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 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= 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.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= 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 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 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.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
github.com/shirou/gopsutil/v4 v4.25.12 h1:e7PvW/0RmJ8p8vPGJH4jvNkOyLmbkXgXW4m6ZPic6CY= github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
github.com/shirou/gopsutil/v4 v4.25.12/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik= github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU= 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 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= 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 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= github.com/testcontainers/testcontainers-go v0.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A=
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= 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.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 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
@@ -257,61 +241,90 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 h1:5RgvxieNq9tS3ewrV1vnODvbHPfKUIJcYtF9Cvz+6aQ=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= go.opentelemetry.io/contrib/bridges/otelslog v0.19.0/go.mod h1:iTBIdNwx/xmUhfgJs6+84S4dIK059811cO1eUBjKcHY=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/contrib/bridges/prometheus v0.69.0 h1:saQoWg5845Q8TojpqeVStS7zGwVZ6bc5W2PJavTPiBM=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/contrib/bridges/prometheus v0.69.0/go.mod h1:AAaS6xs5AyqMdR3Ir0nSWK+QudL2XM8Vbw5INzUxNc8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= go.opentelemetry.io/contrib/exporters/autoexport v0.69.0 h1:R3jsCoTIzv0BiYNhW0axyswn/6SMJ8xL1OuGxvni1Kw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= go.opentelemetry.io/contrib/exporters/autoexport v0.69.0/go.mod h1:m07gqyr2QhQxKOKb5vqKCCBtLH3uqlNYR7PU/FISXVU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0/go.mod h1:MP4eemTiI9zC8fgg+DYynhYDYf3ba72S376TvP+Ye0Q=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug=
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA=
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= 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= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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-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-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.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= 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= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 h1:g0RAkxK/smSu/iRwC/KIX1mwUoVJtk2OjbgaeS4DmUM=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= 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-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 h1:9HZDLIdYBJXAnaFOr9WHrKVycfpY+75s9HGadC0305A=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= 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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -322,3 +335,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=

View File

@@ -0,0 +1,32 @@
receivers:
otlp:
protocols:
grpc:
endpoint: "0.0.0.0:4317"
processors:
batch:
exporters:
otlp/openobserve:
endpoint: "openobserve:5081"
tls:
insecure: true
headers:
Authorization: "Basic dGVsZW1ldHJ5QGdvdGVuYmVyZy5kZXY6dGVsZW1ldHJ5"
organization: "default"
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlp/openobserve]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [otlp/openobserve]
logs:
receivers: [otlp]
processors: [batch]
exporters: [otlp/openobserve]

107
package-lock.json generated
View File

@@ -5,9 +5,9 @@
"packages": { "packages": {
"": { "": {
"devDependencies": { "devDependencies": {
"prettier": "3.8.1", "prettier": "3.9.6",
"prettier-plugin-gherkin": "^3.1.3", "prettier-plugin-gherkin": "^3.1.3",
"prettier-plugin-sh": "^0.18.0" "prettier-plugin-sh": "^0.19.0"
} }
}, },
"node_modules/@cucumber/gherkin": { "node_modules/@cucumber/gherkin": {
@@ -34,15 +34,80 @@
} }
}, },
"node_modules/@reteps/dockerfmt": { "node_modules/@reteps/dockerfmt": {
"version": "0.3.6", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.3.6.tgz", "resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.5.4.tgz",
"integrity": "sha512-Tb5wIMvBf/nLejTQ61krK644/CEMB/cpiaIFXqGApfGqO3GwcR3qnI0DbmkFVCl2OyEp8LnLX3EkucoL0+tbFg==", "integrity": "sha512-HEGgXVVOb+JtGUSSzXl/XPKFIZjMDTUoHarCjaQdkY+cb5M9K/O3b5xm+x0IPIk3SfHurbc0bSgcFsQlzjitxA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": {
"dockerfmt": "dist/launcher.js"
},
"engines": { "engines": {
"node": "^v12.20.0 || ^14.13.0 || >=16.0.0" "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": { "node_modules/@types/uuid": {
"version": "10.0.0", "version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
@@ -58,9 +123,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.8.1", "version": "3.9.6",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@@ -86,14 +151,14 @@
} }
}, },
"node_modules/prettier-plugin-sh": { "node_modules/prettier-plugin-sh": {
"version": "0.18.0", "version": "0.19.0",
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.18.0.tgz", "resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.19.0.tgz",
"integrity": "sha512-cW1XL27FOJQ/qGHOW6IHwdCiNWQsAgK+feA8V6+xUTaH0cD3Mh+tFAtBvEEWvuY6hTDzRV943Fzeii+qMOh7nQ==", "integrity": "sha512-39VXFZH/cOGtcuu8aeSvqp/hhwomOR4QroZUj+jBz2cNb3os9s0sqFZSNlYts6jdtLLDU7D2YT3Z1+abtb7adQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@reteps/dockerfmt": "^0.3.6", "@reteps/dockerfmt": "^0.5.4",
"sh-syntax": "^0.5.8" "sh-syntax": "^0.6.0"
}, },
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=16.0.0"
@@ -113,14 +178,11 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/sh-syntax": { "node_modules/sh-syntax": {
"version": "0.5.8", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.5.8.tgz", "resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.6.0.tgz",
"integrity": "sha512-JfVoxf4FxQI5qpsPbkHhZo+n6N9YMJobyl4oGEUBb/31oQYlgTjkXQD8PBiafS2UbWoxrTO0Z5PJUBXEPAG1Zw==", "integrity": "sha512-52VK6z/cdZHv7UURjIcwfBUQZrAhIEEe0bY4lrkfypjnFIKsDZdD3Uaz/dBiw/sF8BeX0Mssv140s8EnrsJ9dQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
},
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=16.0.0"
}, },
@@ -128,13 +190,6 @@
"url": "https://opencollective.com/sh-syntax" "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": { "node_modules/uuid": {
"version": "11.0.5", "version": "11.0.5",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz",

View File

@@ -1,7 +1,7 @@
{ {
"devDependencies": { "devDependencies": {
"prettier": "3.8.1", "prettier": "3.9.6",
"prettier-plugin-gherkin": "^3.1.3", "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

@@ -6,17 +6,22 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log/slog"
"os/exec" "os/exec"
"path/filepath"
"strings" "strings"
"syscall" "syscall"
"go.uber.org/zap" "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]. // Cmd wraps an [exec.Cmd].
type Cmd struct { type Cmd struct {
ctx context.Context ctx context.Context
logger *zap.Logger logger *slog.Logger
process *exec.Cmd process *exec.Cmd
} }
@@ -25,13 +30,13 @@ type Cmd struct {
// children without creating orphans. // children without creating orphans.
// //
// See https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773. // See https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773.
func Command(logger *zap.Logger, binPath string, args ...string) *Cmd { func Command(logger *slog.Logger, binPath string, args ...string) *Cmd {
cmd := exec.Command(binPath, args...) cmd := exec.Command(binPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
return &Cmd{ return &Cmd{
ctx: nil, ctx: nil,
logger: logger.Named(strings.ReplaceAll(binPath, "/", "")), logger: logger.With(slog.String("logger", strings.ReplaceAll(binPath, "/", ""))),
process: cmd, process: cmd,
} }
} }
@@ -41,7 +46,7 @@ func Command(logger *zap.Logger, binPath string, args ...string) *Cmd {
// children without creating orphans. // children without creating orphans.
// //
// See https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773. // See https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773.
func CommandContext(ctx context.Context, logger *zap.Logger, binPath string, args ...string) (*Cmd, error) { func CommandContext(ctx context.Context, logger *slog.Logger, binPath string, args ...string) (*Cmd, error) {
if ctx == nil { if ctx == nil {
return nil, errors.New("nil context") return nil, errors.New("nil context")
} }
@@ -51,11 +56,18 @@ func CommandContext(ctx context.Context, logger *zap.Logger, binPath string, arg
return &Cmd{ return &Cmd{
ctx: ctx, ctx: ctx,
logger: logger.Named(strings.ReplaceAll(binPath, "/", "")), logger: logger.With(slog.String("logger", strings.ReplaceAll(binPath, "/", ""))),
process: cmd, process: cmd,
}, nil }, nil
} }
// SetEnv replaces the environment variables passed to the underlying
// process. When SetEnv is not called, the process inherits the parent's
// environment.
func (cmd *Cmd) SetEnv(env []string) {
cmd.process.Env = env
}
// Start starts the command but does not wait for its completion. // Start starts the command but does not wait for its completion.
func (cmd *Cmd) Start() error { func (cmd *Cmd) Start() error {
err := cmd.pipeOutput() err := cmd.pipeOutput()
@@ -63,7 +75,7 @@ func (cmd *Cmd) Start() error {
return fmt.Errorf("pipe unix process output: %w", err) return fmt.Errorf("pipe unix process output: %w", err)
} }
cmd.logger.Debug(fmt.Sprintf("start unix process: %s", strings.Join(cmd.process.Args, " "))) cmd.logger.DebugContext(context.Background(), fmt.Sprintf("start unix process: %s", strings.Join(cmd.process.Args, " ")))
err = cmd.process.Start() err = cmd.process.Start()
if err != nil { if err != nil {
@@ -86,11 +98,44 @@ func (cmd *Cmd) Wait() error {
// Exec executes the command and waits for its completion or until the context // 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. // 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) { func (cmd *Cmd) Exec() (int, error) {
if cmd.ctx == nil { if cmd.ctx == nil {
return 10, errors.New("nil context") 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() err := cmd.Start()
if err != nil { if err != nil {
if cmd.process.ProcessState == nil { if cmd.process.ProcessState == nil {
@@ -110,7 +155,7 @@ func (cmd *Cmd) Exec() (int, error) {
case err = <-errChan: case err = <-errChan:
errProc := cmd.Kill() errProc := cmd.Kill()
if errProc != nil { if errProc != nil {
cmd.logger.Error(errProc.Error()) cmd.logger.ErrorContext(context.Background(), errProc.Error())
} }
if err == nil { if err == nil {
@@ -125,18 +170,30 @@ func (cmd *Cmd) Exec() (int, error) {
case <-cmd.ctx.Done(): case <-cmd.ctx.Done():
errProc := cmd.Kill() errProc := cmd.Kill()
if errProc != nil { if errProc != nil {
cmd.logger.Error(errProc.Error()) cmd.logger.ErrorContext(context.Background(), errProc.Error())
} }
return 62, fmt.Errorf("context done: %w", cmd.ctx.Err()) return 62, fmt.Errorf("context done: %w", cmd.ctx.Err())
} }
} }
// 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. // pipeOutput creates logs entries according to the process stdout and stderr.
// It does nothing if the logging level is not debug. // It does nothing if the logging level is not debug.
func (cmd *Cmd) pipeOutput() error { func (cmd *Cmd) pipeOutput() error {
checkedEntry := cmd.logger.Check(zap.DebugLevel, "check for debug level before piping unix process output") if !cmd.logger.Enabled(context.Background(), slog.LevelDebug) {
if checkedEntry == nil {
return nil return nil
} }
@@ -152,12 +209,12 @@ func (cmd *Cmd) pipeOutput() error {
// logCommandOutput creates logs entries according to a reader // logCommandOutput creates logs entries according to a reader
// (either stdout or stderr). // (either stdout or stderr).
logCommandOutput := func(logger *zap.Logger, reader io.ReadCloser) { logCommandOutput := func(logger *slog.Logger, reader io.ReadCloser) {
r := bufio.NewReader(reader) r := bufio.NewReader(reader)
defer func(reader io.ReadCloser) { defer func(reader io.ReadCloser) {
err := reader.Close() err := reader.Close()
if err != nil && !strings.Contains(err.Error(), "file already closed") { if err != nil && !strings.Contains(err.Error(), "file already closed") {
logger.Error(fmt.Sprintf("close reader: %s", err)) logger.ErrorContext(context.Background(), fmt.Sprintf("close reader: %s", err))
} }
}(reader) }(reader)
@@ -165,20 +222,20 @@ func (cmd *Cmd) pipeOutput() error {
line, _, err := r.ReadLine() line, _, err := r.ReadLine()
if err != nil { if err != nil {
if err != io.EOF && !strings.Contains(err.Error(), "file already closed") { if err != io.EOF && !strings.Contains(err.Error(), "file already closed") {
logger.Error(fmt.Sprintf("pipe unix process output error: %s", err)) logger.ErrorContext(context.Background(), fmt.Sprintf("pipe unix process output error: %s", err))
} }
break break
} }
if len(line) != 0 { if len(line) != 0 {
logger.Debug(string(line)) logger.DebugContext(context.Background(), string(line))
} }
} }
} }
go logCommandOutput(cmd.logger.Named("stdout"), stdout) go logCommandOutput(cmd.logger.With(slog.String("logger", "stdout")), stdout)
go logCommandOutput(cmd.logger.Named("stderr"), stderr) go logCommandOutput(cmd.logger.With(slog.String("logger", "stderr")), stderr)
return nil return nil
} }
@@ -196,13 +253,13 @@ func (cmd *Cmd) Kill() error {
err := syscall.Kill(-cmd.process.Process.Pid, syscall.SIGKILL) err := syscall.Kill(-cmd.process.Process.Pid, syscall.SIGKILL)
if err == nil { if err == nil {
cmd.logger.Debug("unix process killed") cmd.logger.DebugContext(context.Background(), "unix process killed")
return nil return nil
} }
// If the process does not exist anymore, the error is irrelevant. // If the process does not exist anymore, the error is irrelevant.
if strings.Contains(err.Error(), "no such process") { if strings.Contains(err.Error(), "no such process") {
cmd.logger.Debug("unix process already killed") cmd.logger.DebugContext(context.Background(), "unix process already killed")
return nil return nil
} }

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

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
// Package gotenberg provides most of the logic of the module system. // Package gotenberg implements the core module system.
// //
// caddyserver/caddy, licensed under the Apache License 2.0, has significantly // Significantly inspired by caddyserver/caddy, licensed under the Apache
// inspired this module system. // License 2.0.
// //
// More details are available on https://caddyserver.com/. // See https://caddyserver.com/.
package gotenberg package gotenberg

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

@@ -12,40 +12,57 @@ import (
// ErrFiltered happens if a value is filtered by the [FilterDeadline] function. // ErrFiltered happens if a value is filtered by the [FilterDeadline] function.
var ErrFiltered = errors.New("value filtered") var ErrFiltered = errors.New("value filtered")
// FilterDeadline checks if given value is allowed and not denied according to // FilterDeadline checks if the given value is allowed and not denied according
// regex patterns. It returns a [context.DeadlineExceeded] if it takes too long // to regex patterns. The allowed list uses OR semantics (value must match at
// to process. // least one pattern). The denied list uses OR semantics (value is denied if it
func FilterDeadline(allowed, denied *regexp2.Regexp, s string, deadline time.Time) error { // matches any pattern). It returns a [context.DeadlineExceeded] if it takes
// FIXME: not ideal to compile everytime, but is there another way to create a clone? // too long to process.
if allowed.String() != "" { func FilterDeadline(allowed, denied []*regexp2.Regexp, s string, deadline time.Time) error {
allow := regexp2.MustCompile(allowed.String(), 0) if len(allowed) > 0 {
allow.MatchTimeout = time.Until(deadline) matched := false
ok, err := allow.MatchString(s) for _, pattern := range allowed {
if err != nil { // FIXME: not ideal to compile everytime, but is there another way to create a clone?
if time.Now().After(deadline) { clone := regexp2.MustCompile(pattern.String(), 0)
return context.DeadlineExceeded clone.MatchTimeout = time.Until(deadline)
ok, err := clone.MatchString(s)
if err != nil {
if time.Now().After(deadline) {
return context.DeadlineExceeded
}
return fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), s, err)
}
if ok {
matched = true
break
} }
return fmt.Errorf("'%s' cannot handle '%s': %w", allow.String(), s, err)
} }
if !ok {
return fmt.Errorf("'%s' does not match the expression from the allowed list: %w", s, ErrFiltered) if !matched {
return fmt.Errorf("'%s' does not match any expression from the allowed list: %w", s, ErrFiltered)
} }
} }
if denied.String() != "" { if len(denied) > 0 {
deny := regexp2.MustCompile(denied.String(), 0) for _, pattern := range denied {
deny.MatchTimeout = time.Until(deadline) clone := regexp2.MustCompile(pattern.String(), 0)
clone.MatchTimeout = time.Until(deadline)
ok, err := deny.MatchString(s) ok, err := clone.MatchString(s)
if err != nil { if err != nil {
if time.Now().After(deadline) { if time.Now().After(deadline) {
return context.DeadlineExceeded return context.DeadlineExceeded
}
return fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), s, err)
}
if ok {
return fmt.Errorf("'%s' matches the expression from the denied list: %w", s, ErrFiltered)
} }
return fmt.Errorf("'%s' cannot handle '%s': %w", deny.String(), s, err)
}
if ok {
return fmt.Errorf("'%s' matches the expression from the denied list: %w", s, ErrFiltered)
} }
} }

View File

@@ -12,8 +12,8 @@ import (
func TestFilterDeadline(t *testing.T) { func TestFilterDeadline(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
allowed *regexp2.Regexp allowed []*regexp2.Regexp
denied *regexp2.Regexp denied []*regexp2.Regexp
s string s string
deadline time.Time deadline time.Time
expectError bool expectError bool
@@ -21,17 +21,17 @@ func TestFilterDeadline(t *testing.T) {
}{ }{
{ {
scenario: "DeadlineExceeded (allowed)", scenario: "DeadlineExceeded (allowed)",
allowed: regexp2.MustCompile("foo", 0), allowed: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)},
denied: regexp2.MustCompile("", 0), denied: nil,
s: "foo", s: "foo",
deadline: time.Now().Add(time.Duration(-1) * time.Hour), deadline: time.Now().Add(time.Duration(-1) * time.Hour),
expectError: true, expectError: true,
expectedError: context.DeadlineExceeded, expectedError: context.DeadlineExceeded,
}, },
{ {
scenario: "ErrFiltered (allowed)", scenario: "ErrFiltered (allowed, no match)",
allowed: regexp2.MustCompile("foo", 0), allowed: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)},
denied: regexp2.MustCompile("", 0), denied: nil,
s: "bar", s: "bar",
deadline: time.Now().Add(time.Duration(5) * time.Second), deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: true, expectError: true,
@@ -39,8 +39,8 @@ func TestFilterDeadline(t *testing.T) {
}, },
{ {
scenario: "DeadlineExceeded (denied)", scenario: "DeadlineExceeded (denied)",
allowed: regexp2.MustCompile("", 0), allowed: nil,
denied: regexp2.MustCompile("foo", 0), denied: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)},
s: "foo", s: "foo",
deadline: time.Now().Add(time.Duration(-1) * time.Hour), deadline: time.Now().Add(time.Duration(-1) * time.Hour),
expectError: true, expectError: true,
@@ -48,21 +48,55 @@ func TestFilterDeadline(t *testing.T) {
}, },
{ {
scenario: "ErrFiltered (denied)", scenario: "ErrFiltered (denied)",
allowed: regexp2.MustCompile("", 0), allowed: nil,
denied: regexp2.MustCompile("foo", 0), denied: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)},
s: "foo", s: "foo",
deadline: time.Now().Add(time.Duration(5) * time.Second), deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: true, expectError: true,
expectedError: ErrFiltered, expectedError: ErrFiltered,
}, },
{ {
scenario: "success", scenario: "success (empty lists)",
allowed: regexp2.MustCompile("", 0), allowed: nil,
denied: regexp2.MustCompile("", 0), denied: nil,
s: "foo", s: "foo",
deadline: time.Now().Add(time.Duration(5) * time.Second), deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: false, expectError: false,
}, },
{
scenario: "multi-pattern allow list, second matches",
allowed: []*regexp2.Regexp{regexp2.MustCompile("^https://", 0), regexp2.MustCompile("^file:///tmp/", 0)},
denied: nil,
s: "file:///tmp/abc/index.html",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: false,
},
{
scenario: "multi-pattern allow list, none matches",
allowed: []*regexp2.Regexp{regexp2.MustCompile("^https://", 0), regexp2.MustCompile("^ftp://", 0)},
denied: nil,
s: "file:///tmp/abc/index.html",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: true,
expectedError: ErrFiltered,
},
{
scenario: "multi-pattern deny list, second matches",
allowed: nil,
denied: []*regexp2.Regexp{regexp2.MustCompile("^ftp://", 0), regexp2.MustCompile("^file:.*", 0)},
s: "file:///etc/passwd",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: true,
expectedError: ErrFiltered,
},
{
scenario: "https URL passes deny list targeting file://",
allowed: nil,
denied: []*regexp2.Regexp{regexp2.MustCompile("^file:.*", 0)},
s: "https://example.com",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: false,
},
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
err := FilterDeadline(tc.allowed, tc.denied, tc.s, tc.deadline) err := FilterDeadline(tc.allowed, tc.denied, tc.s, tc.deadline)

View File

@@ -222,3 +222,32 @@ func (f *ParsedFlags) MustDeprecatedRegexp(deprecated string, newName string) *r
return f.MustRegexp(newName) return f.MustRegexp(newName)
} }
// MustRegexpSlice returns a slice of compiled regular expressions from a
// string-slice flag given by name. Empty strings are skipped.
// It panics if an error occurs.
func (f *ParsedFlags) MustRegexpSlice(name string) []*regexp2.Regexp {
vals := f.MustStringSlice(name)
var regexps []*regexp2.Regexp
for _, val := range vals {
if val == "" {
continue
}
regexps = append(regexps, regexp2.MustCompile(val, 0))
}
return regexps
}
// MustDeprecatedRegexpSlice returns the slice of compiled regular expressions
// of a deprecated flag if it was explicitly set or the slice of the new flag.
// It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedRegexpSlice(deprecated string, newName string) []*regexp2.Regexp {
if f.Changed(deprecated) {
return f.MustRegexpSlice(deprecated)
}
return f.MustRegexpSlice(newName)
}

View File

@@ -6,6 +6,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/dlclark/regexp2"
flag "github.com/spf13/pflag" flag "github.com/spf13/pflag"
) )
@@ -833,3 +834,120 @@ func TestParsedFlags_MustDeprecatedRegexp(t *testing.T) {
}) })
} }
} }
func TestParsedFlags_MustRegexpSlice(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.StringSlice("foo", []string{}, "")
err := fs.Parse([]string{"--foo=^file:.*", "--foo=^ftp://.*"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
parsedFlags := ParsedFlags{FlagSet: fs}
for _, tc := range []struct {
scenario string
name string
expectPanic bool
expectLen int
}{
{
scenario: "success with multiple patterns",
name: "foo",
expectPanic: false,
expectLen: 2,
},
{
scenario: "non-existing flag",
name: "bar",
expectPanic: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
if tc.expectPanic {
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic but got none")
}
}()
}
if !tc.expectPanic {
defer func() {
if r := recover(); r != nil {
t.Fatalf("expected no panic but got: %v", r)
}
}()
}
result := parsedFlags.MustRegexpSlice(tc.name)
if !tc.expectPanic && len(result) != tc.expectLen {
t.Errorf("expected %d regexps but got %d", tc.expectLen, len(result))
}
})
}
// Test empty strings are skipped.
fs2 := flag.NewFlagSet("tests2", flag.ContinueOnError)
fs2.StringSlice("baz", []string{""}, "")
err = fs2.Parse([]string{})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
parsedFlags2 := ParsedFlags{FlagSet: fs2}
result := parsedFlags2.MustRegexpSlice("baz")
if len(result) != 0 {
t.Errorf("expected 0 regexps for empty strings but got %d", len(result))
}
}
func TestParsedFlags_MustDeprecatedRegexpSlice(t *testing.T) {
for _, tc := range []struct {
scenario string
rawFlags []string
expectPattern string
}{
{
scenario: "deprecated flag value",
rawFlags: []string{"--foo=^file:.*"},
expectPattern: "^file:.*",
},
{
scenario: "non-deprecated flag value",
rawFlags: []string{"--bar=^ftp://.*"},
expectPattern: "^ftp://.*",
},
{
scenario: "deprecated flag value > non-deprecated flag value",
rawFlags: []string{"--foo=^file:.*", "--bar=^ftp://.*"},
expectPattern: "^file:.*",
},
} {
t.Run(tc.scenario, func(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.StringSlice("foo", []string{}, "")
fs.StringSlice("bar", []string{}, "")
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
actual := parsedFlags.MustDeprecatedRegexpSlice("foo", "bar")
if len(actual) != 1 {
t.Fatalf("expected 1 regexp but got %d", len(actual))
}
if actual[0].String() != tc.expectPattern {
t.Errorf("expected pattern '%s' but got '%s'", tc.expectPattern, actual[0].String())
}
})
}
_ = regexp2.None // Keep import alive.
}

View File

@@ -1,19 +1,19 @@
package gotenberg package gotenberg
import ( import (
"context"
"fmt" "fmt"
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"go.uber.org/zap"
) )
// GarbageCollect scans the root path and deletes files or directories with // GarbageCollect scans the root path and deletes files or directories with
// names containing specific substrings and before a given expiration time. // names containing specific substrings and before a given expiration time.
func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string, expirationTime time.Time) error { func GarbageCollect(ctx context.Context, logger *slog.Logger, rootPath string, includeSubstr []string, expirationTime time.Time) error {
logger = logger.Named("gc") logger = logger.With(slog.String("logger", "gc"))
// To make sure that the next Walk method stays on // To make sure that the next Walk method stays on
// the root level of the considered path, we have to // the root level of the considered path, we have to
@@ -38,12 +38,12 @@ func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string,
for _, substr := range includeSubstr { for _, substr := range includeSubstr {
if (strings.Contains(info.Name(), substr) || path == substr) && info.ModTime().Before(expirationTime) { if (strings.Contains(info.Name(), substr) || path == substr) && info.ModTime().Before(expirationTime) {
err := os.RemoveAll(path) err := os.RemoveAll(path) //nolint:gosec // G122: rootPath is a trusted internal working directory
if err != nil { if err != nil {
return fmt.Errorf("garbage collect '%s': %w", path, err) return fmt.Errorf("garbage collect '%s': %w", path, err)
} }
logger.Debug(fmt.Sprintf("'%s' removed", path)) logger.DebugContext(ctx, fmt.Sprintf("'%s' removed", path))
return skipDirOrNil(info) return skipDirOrNil(info)
} }

View File

@@ -1,14 +1,15 @@
package gotenberg package gotenberg
import ( import (
"context"
"fmt" "fmt"
"log/slog"
"os" "os"
"path" "path"
"testing" "testing"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap"
) )
func TestGarbageCollect(t *testing.T) { func TestGarbageCollect(t *testing.T) {
@@ -66,7 +67,7 @@ func TestGarbageCollect(t *testing.T) {
} }
}() }()
err := GarbageCollect(zap.NewNop(), tc.rootPath, tc.includeSubstr, time.Now()) err := GarbageCollect(context.Background(), slog.New(slog.DiscardHandler), tc.rootPath, tc.includeSubstr, time.Now())
if !tc.expectError && err != nil { if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)

View File

@@ -1,9 +1,8 @@
package logging package log
import ( import (
"fmt" "fmt"
"log/slog"
"go.uber.org/zap/zapcore"
) )
// Foreground colors. // Foreground colors.
@@ -25,23 +24,15 @@ func (c color) Add(s string) string {
return fmt.Sprintf("\x1b[%dm%s\x1b[0m", uint8(c), s) return fmt.Sprintf("\x1b[%dm%s\x1b[0m", uint8(c), s)
} }
func levelToColor(l zapcore.Level) color { func levelToColor(l slog.Level) color {
switch l { switch l {
case zapcore.DebugLevel: case slog.LevelDebug:
return cyan return cyan
case zapcore.InfoLevel: case slog.LevelInfo:
return blue return blue
case zapcore.WarnLevel: case slog.LevelWarn:
return yellow return yellow
case zapcore.ErrorLevel: case slog.LevelError:
return red
case zapcore.DPanicLevel:
return red
case zapcore.PanicLevel:
return red
case zapcore.FatalLevel:
return red
case zapcore.InvalidLevel:
return red return red
default: default:
return red return red

View File

@@ -0,0 +1,2 @@
// Package log gathers internal logging utilities.
package log

View File

@@ -0,0 +1,18 @@
package log
import "log/slog"
func gcpSeverity(l slog.Level) string {
switch {
case l < slog.LevelInfo:
return "DEBUG"
case l < slog.LevelWarn:
return "INFO"
case l < slog.LevelError:
return "WARNING"
case l >= slog.LevelError:
return "ERROR"
default:
return "DEFAULT"
}
}

View File

@@ -0,0 +1,171 @@
package log
import (
"context"
"errors"
"log/slog"
)
type gotenbergHandler struct {
slog.Handler
fieldsPrefix string
loggerName string
}
func NewGotenbergHandler(next slog.Handler, prefix string) slog.Handler {
return &gotenbergHandler{Handler: next, fieldsPrefix: prefix}
}
func (h *gotenbergHandler) Handle(ctx context.Context, r slog.Record) error {
var newAttrs []slog.Attr
if h.loggerName != "" {
newAttrs = append(newAttrs, slog.String("logger", h.loggerName))
}
var needsNewRecord bool
if len(newAttrs) > 0 {
needsNewRecord = true
}
if h.fieldsPrefix != "" {
r.Attrs(func(a slog.Attr) bool {
if a.Key == "logger" || a.Key == "correlation_id" || a.Key == "trace_id" || a.Key == "span_id" {
newAttrs = append(newAttrs, a)
needsNewRecord = true
return true
}
a.Key = h.fieldsPrefix + "_" + a.Key
newAttrs = append(newAttrs, a)
needsNewRecord = true
return true
})
} else if needsNewRecord {
r.Attrs(func(a slog.Attr) bool {
newAttrs = append(newAttrs, a)
return true
})
}
if needsNewRecord {
newR := slog.NewRecord(r.Time, r.Level, r.Message, r.PC)
newR.AddAttrs(newAttrs...)
r = newR
}
return h.Handler.Handle(ctx, r)
}
func (h *gotenbergHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
var prefixed []slog.Attr
newLoggerName := h.loggerName
for _, a := range attrs {
if a.Key == "logger" {
if newLoggerName == "" {
newLoggerName = a.Value.String()
} else {
newLoggerName = newLoggerName + "." + a.Value.String()
}
continue
}
if h.fieldsPrefix != "" {
if a.Key == "correlation_id" || a.Key == "trace_id" || a.Key == "span_id" {
// Don't prefix these keys
} else {
a.Key = h.fieldsPrefix + "_" + a.Key
}
}
prefixed = append(prefixed, a)
}
newHandler := h.Handler
if len(prefixed) > 0 {
newHandler = h.Handler.WithAttrs(prefixed)
}
return &gotenbergHandler{
Handler: newHandler,
fieldsPrefix: h.fieldsPrefix,
loggerName: newLoggerName,
}
}
func (h *gotenbergHandler) WithGroup(name string) slog.Handler {
return &gotenbergHandler{
Handler: h.Handler.WithGroup(name),
fieldsPrefix: h.fieldsPrefix,
loggerName: h.loggerName,
}
}
type multiHandler struct {
handlers []slog.Handler
}
func FanOut(handlers ...slog.Handler) slog.Handler {
return &multiHandler{handlers: handlers}
}
func (m *multiHandler) Enabled(ctx context.Context, level slog.Level) bool {
for _, h := range m.handlers {
if h.Enabled(ctx, level) {
return true
}
}
return false
}
func (m *multiHandler) Handle(ctx context.Context, r slog.Record) error {
var errs []error
for _, h := range m.handlers {
if h.Enabled(ctx, r.Level) {
if err := h.Handle(ctx, r.Clone()); err != nil {
errs = append(errs, err)
}
}
}
return errors.Join(errs...)
}
func (m *multiHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
cloned := make([]slog.Handler, len(m.handlers))
for i, h := range m.handlers {
cloned[i] = h.WithAttrs(attrs)
}
return &multiHandler{handlers: cloned}
}
func (m *multiHandler) WithGroup(name string) slog.Handler {
cloned := make([]slog.Handler, len(m.handlers))
for i, h := range m.handlers {
cloned[i] = h.WithGroup(name)
}
return &multiHandler{handlers: cloned}
}
type levelHandler struct {
slog.Handler
level slog.Level
}
func LevelFilter(next slog.Handler, level slog.Level) slog.Handler {
return &levelHandler{Handler: next, level: level}
}
func (h *levelHandler) Enabled(ctx context.Context, level slog.Level) bool {
return level >= h.level && h.Handler.Enabled(ctx, level)
}
func (h *levelHandler) Handle(ctx context.Context, r slog.Record) error {
return h.Handler.Handle(ctx, r)
}
func (h *levelHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &levelHandler{Handler: h.Handler.WithAttrs(attrs), level: h.level}
}
func (h *levelHandler) WithGroup(name string) slog.Handler {
return &levelHandler{Handler: h.Handler.WithGroup(name), level: h.level}
}

View File

@@ -0,0 +1,37 @@
package log
import (
"log/slog"
"sync"
)
// InitLogger initializes the global logger.
func InitLogger(handler slog.Handler) {
if logger != nil {
return
}
mu.Lock()
defer mu.Unlock()
// Double check: ensure it wasn't initialized while we waited for the lock.
if logger != nil {
return
}
logger = slog.New(handler)
}
// Logger returns the global logger.
func Logger() *slog.Logger {
mu.Lock()
defer mu.Unlock()
return logger
}
// logger is Singleton so that we instantiate our [slog.Logger] only once.
var (
logger *slog.Logger
mu sync.Mutex
)

View File

@@ -0,0 +1,137 @@
package log
import (
"context"
"log/slog"
"os"
"strings"
"go.opentelemetry.io/otel/trace"
"golang.org/x/term"
)
const (
autoLoggingFormat = "auto"
jsonLoggingFormat = "json"
textLoggingFormat = "text"
)
// traceContextHandler is an internal wrapper that specifically injects
// trace_id and span_id into the log record before they are written to the standard output.
type traceContextHandler struct {
slog.Handler
}
func (h traceContextHandler) Handle(ctx context.Context, r slog.Record) error {
if spanCtx := trace.SpanContextFromContext(ctx); spanCtx.IsValid() {
// Since slog.Record is immutable with regards to adding attributes in-place,
// we must clone and add them.
newR := slog.NewRecord(r.Time, r.Level, r.Message, r.PC)
r.Attrs(func(a slog.Attr) bool {
newR.AddAttrs(a)
return true
})
newR.AddAttrs(
slog.String("trace_id", spanCtx.TraceID().String()),
slog.String("span_id", spanCtx.SpanID().String()),
)
r = newR
}
return h.Handler.Handle(ctx, r)
}
func (h traceContextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return traceContextHandler{Handler: h.Handler.WithAttrs(attrs)}
}
func (h traceContextHandler) WithGroup(name string) slog.Handler {
return traceContextHandler{Handler: h.Handler.WithGroup(name)}
}
// NewStdHandler returns a [slog.Handler] instance for the standard output.
// 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()))
// Normalize the log format based on the output device.
if format == autoLoggingFormat {
if isTerminal {
format = textLoggingFormat
} else {
format = jsonLoggingFormat
}
}
opts := &slog.HandlerOptions{
Level: level,
}
opts.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr {
// Configure level encoding based on format and GCP settings.
if a.Key == slog.LevelKey {
l := a.Value.Any().(slog.Level)
switch {
case format == textLoggingFormat && isTerminal:
if enableGcpFields {
a.Value = slog.StringValue(gcpSeverityColorEncoder(l))
} else {
a.Value = slog.StringValue(levelToColor(l).Add(l.String()))
}
case enableGcpFields && format != textLoggingFormat:
a.Key = "severity"
a.Value = slog.StringValue(gcpSeverity(l))
default:
if levelCase == upperLevelCase {
a.Value = slog.StringValue(l.String())
} else {
a.Value = slog.StringValue(strings.ToLower(l.String()))
}
}
}
if a.Key == slog.TimeKey {
if enableGcpFields && format != textLoggingFormat {
a.Key = "time"
} else {
a.Key = "ts"
}
if isTerminal {
a.Value = slog.StringValue(a.Value.Time().Local().Format("2006/01/02 15:04:05.000"))
} else if !enableGcpFields {
a.Value = slog.Float64Value(float64(a.Value.Time().UnixNano()) / 1e9)
}
}
if a.Key == slog.MessageKey {
if enableGcpFields && format != textLoggingFormat {
a.Key = "message"
} else {
a.Key = "msg"
}
}
return a
}
var handler slog.Handler
if format == textLoggingFormat {
handler = slog.NewTextHandler(os.Stderr, opts)
} else {
handler = slog.NewJSONHandler(os.Stderr, opts)
}
return traceContextHandler{Handler: handler}, nil
}
func gcpSeverityColorEncoder(l slog.Level) string {
severity := gcpSeverity(l)
c := levelToColor(l)
return c.Add(severity)
}

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

@@ -0,0 +1,24 @@
// Package otel gathers initialization utilities for OpenTelemetry
// instrumentation.
//
// 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

@@ -0,0 +1,188 @@
package otel
import (
"context"
"fmt"
"log/slog"
"os"
"sync/atomic"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/contrib/exporters/autoexport"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/log/global"
"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.43.0"
)
// buildResource assembles the OpenTelemetry resource shared by the tracer,
// meter, and logger providers. Detection is best-effort: a detector or merge
// failure is logged and the build proceeds with whatever was gathered, so a
// flaky environment never prevents telemetry from starting.
//
// The semconv version imported here must match the one the SDK resource
// detectors use (go.opentelemetry.io/otel/sdk/resource). Drift makes
// [resource.Merge] fail with [resource.ErrSchemaURLConflict] and strips the
// schema URL off every exported signal.
func buildResource(ctx context.Context, logger *slog.Logger, serviceName, serviceVersion string) *resource.Resource {
base := resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName(serviceName),
semconv.ServiceVersion(serviceVersion),
)
// Granular detectors only. The WithProcess() bundle is deliberately omitted
// because it adds process.command_args/process.command_line, which can carry
// proxy credentials and host-resolver rules passed on the command line.
detected, err := resource.New(ctx,
resource.WithFromEnv(),
resource.WithTelemetrySDK(),
resource.WithHost(),
resource.WithHostID(),
resource.WithOS(),
resource.WithContainer(),
resource.WithProcessPID(),
resource.WithProcessExecutableName(),
resource.WithProcessExecutablePath(),
resource.WithProcessRuntimeName(),
resource.WithProcessRuntimeVersion(),
resource.WithProcessRuntimeDescription(),
)
if err != nil {
logger.WarnContext(ctx, fmt.Sprintf("partially detect OpenTelemetry resource: %s", err))
}
if detected == nil {
return base
}
// A schema URL conflict still yields a resource holding every attribute, only
// without a schema URL. Keep it: falling back to base would drop the host,
// OS, container, process, and OTEL_RESOURCE_ATTRIBUTES data.
merged, err := resource.Merge(detected, base)
if err != nil {
logger.WarnContext(ctx, fmt.Sprintf("merge OpenTelemetry resource: %s", err))
}
if merged == nil {
return base
}
return merged
}
// InitTracerProvider initializes the OpenTelemetry tracer provider.
func InitTracerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, err error) {
initOtelLogger(logger)
ctx := context.Background()
res := buildResource(ctx, logger, serviceName, serviceVersion)
traceOpts := []trace.TracerProviderOption{
trace.WithResource(res),
}
traceExporter, err := autoexport.NewSpanExporter(ctx)
if err != nil {
return nil, err
}
if !autoexport.IsNoneSpanExporter(traceExporter) {
traceOpts = append(traceOpts, trace.WithBatcher(traceExporter))
}
traceProvider := trace.NewTracerProvider(traceOpts...)
otel.SetTracerProvider(traceProvider)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
return traceProvider.Shutdown, nil
}
// InitMeterProvider initializes the OpenTelemetry meter provider.
func InitMeterProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, err error) {
initOtelLogger(logger)
ctx := context.Background()
res := buildResource(ctx, logger, serviceName, serviceVersion)
metricOpts := []metric.Option{
metric.WithResource(res),
}
metricOpts = append(metricOpts, exemplarFilterOptions()...)
metricReader, err := autoexport.NewMetricReader(ctx)
if err != nil {
return nil, err
}
if !autoexport.IsNoneMetricReader(metricReader) {
metricOpts = append(metricOpts, metric.WithReader(metricReader))
}
meterProvider := metric.NewMeterProvider(metricOpts...)
otel.SetMeterProvider(meterProvider)
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()
res := buildResource(ctx, logger, serviceName, serviceVersion)
logOpts := []log.LoggerProviderOption{
log.WithResource(res),
}
logExporter, err := autoexport.NewLogExporter(ctx)
if err != nil {
return nil, nil, err
}
if !autoexport.IsNoneLogExporter(logExporter) {
logOpts = append(logOpts, log.WithProcessor(log.NewBatchProcessor(logExporter)))
}
loggerProvider := log.NewLoggerProvider(logOpts...)
otelHandler := otelslog.NewHandler(serviceName, otelslog.WithLoggerProvider(loggerProvider))
global.SetLoggerProvider(loggerProvider)
return loggerProvider.Shutdown, otelHandler, nil
}
func initOtelLogger(logger *slog.Logger) {
if otlpLoggerInitialized.Load() {
return
}
otel.SetErrorHandler(otel.ErrorHandlerFunc(func(err error) {
logger.Error(err.Error())
}))
otlpLoggerInitialized.Store(true)
}
var otlpLoggerInitialized atomic.Bool

View File

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

View File

@@ -1,57 +1 @@
package gotenberg package gotenberg
import (
"fmt"
"github.com/hashicorp/go-retryablehttp"
"go.uber.org/zap"
)
// LoggerProvider is an interface for a module that supplies a method for
// creating a [zap.Logger] instance for use by other modules.
//
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
// provider, _ := ctx.Module(new(gotenberg.LoggerProvider))
// logger, _ := provider.(gotenberg.LoggerProvider).Logger(m)
// }
type LoggerProvider interface {
Logger(mod Module) (*zap.Logger, error)
}
// LeveledLogger is a wrapper around a [zap.Logger] so that it may be used by a
// [retryablehttp.Client].
type LeveledLogger struct {
logger *zap.Logger
}
// NewLeveledLogger instantiates a [LeveledLogger].
func NewLeveledLogger(logger *zap.Logger) *LeveledLogger {
return &LeveledLogger{
logger: logger,
}
}
// Error logs a message at the error level using the wrapped zap.Logger.
func (leveled LeveledLogger) Error(msg string, keysAndValues ...interface{}) {
leveled.logger.Error(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Warn logs a message at the warning level using the wrapped zap.Logger.
func (leveled LeveledLogger) Warn(msg string, keysAndValues ...interface{}) {
leveled.logger.Warn(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Info logs a message at the info level using the wrapped zap.Logger.
func (leveled LeveledLogger) Info(msg string, keysAndValues ...interface{}) {
leveled.logger.Info(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Debug logs a message at the debug level using the wrapped zap.Logger.
func (leveled LeveledLogger) Debug(msg string, keysAndValues ...interface{}) {
leveled.logger.Debug(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Interface guards.
var (
_ retryablehttp.LeveledLogger = (*LeveledLogger)(nil)
)

View File

@@ -2,9 +2,8 @@ package gotenberg
import ( import (
"context" "context"
"log/slog"
"os" "os"
"go.uber.org/zap"
) )
// ModuleMock is a mock for the [Module] interface. // ModuleMock is a mock for the [Module] interface.
@@ -35,10 +34,10 @@ func (mod *ValidatorMock) Validate() error {
} }
type DebuggableMock struct { type DebuggableMock struct {
DebugMock func() map[string]interface{} DebugMock func() map[string]any
} }
func (mod *DebuggableMock) Debug() map[string]interface{} { func (mod *DebuggableMock) Debug() map[string]any {
return mod.DebugMock() return mod.DebugMock()
} }
@@ -46,48 +45,93 @@ func (mod *DebuggableMock) Debug() map[string]interface{} {
// //
//nolint:dupl //nolint:dupl
type PdfEngineMock struct { type PdfEngineMock struct {
MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error MergeMock func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
SplitMock func(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
FlattenMock func(ctx context.Context, logger *zap.Logger, inputPath string) error FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath string) error
ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error ConvertMock func(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
ReadMetadataMock func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
EncryptMock func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
EmbedFilesMock func(ctx context.Context, logger *zap.Logger, filePaths []string, 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 *zap.Logger, inputPaths []string, outputPath string) error { func (engine *PdfEngineMock) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
return engine.MergeMock(ctx, logger, inputPaths, outputPath) return engine.MergeMock(ctx, logger, inputPaths, outputPath)
} }
func (engine *PdfEngineMock) Split(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) { func (engine *PdfEngineMock) Split(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) {
return engine.SplitMock(ctx, logger, mode, inputPath, outputDirPath) return engine.SplitMock(ctx, logger, mode, inputPath, outputDirPath)
} }
func (engine *PdfEngineMock) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error { func (engine *PdfEngineMock) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
return engine.FlattenMock(ctx, logger, inputPath) return engine.FlattenMock(ctx, logger, inputPath)
} }
func (engine *PdfEngineMock) Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error { func (engine *PdfEngineMock) Convert(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error {
return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath) return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath)
} }
func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
return engine.ReadMetadataMock(ctx, logger, inputPath) return engine.ReadMetadataMock(ctx, logger, inputPath)
} }
func (engine *PdfEngineMock) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { func (engine *PdfEngineMock) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
return engine.PageCountMock(ctx, logger, inputPath)
}
func (engine *PdfEngineMock) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
return engine.WriteMetadataMock(ctx, logger, metadata, inputPath) return engine.WriteMetadataMock(ctx, logger, metadata, inputPath)
} }
func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { func (engine *PdfEngineMock) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error) {
return engine.EncryptMock(ctx, logger, inputPath, userPassword, ownerPassword) return engine.ReadBookmarksMock(ctx, logger, inputPath)
} }
func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error { func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts EncryptOptions) error {
return engine.EncryptMock(ctx, logger, inputPath, opts)
}
func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
return engine.EmbedFilesMock(ctx, logger, filePaths, inputPath) return engine.EmbedFilesMock(ctx, logger, filePaths, inputPath)
} }
func (engine *PdfEngineMock) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
return engine.EmbedFilesMetadataMock(ctx, logger, metadata, inputPath)
}
func (engine *PdfEngineMock) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error {
return engine.WriteBookmarksMock(ctx, logger, inputPath, bookmarks)
}
func (engine *PdfEngineMock) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error {
return engine.WatermarkMock(ctx, logger, inputPath, stamp)
}
func (engine *PdfEngineMock) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error {
return engine.StampMock(ctx, logger, inputPath, stamp)
}
func (engine *PdfEngineMock) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
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. // PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface.
type PdfEngineProviderMock struct { type PdfEngineProviderMock struct {
PdfEngineMock func() (PdfEngine, error) PdfEngineMock func() (PdfEngine, error)
@@ -99,31 +143,33 @@ func (provider *PdfEngineProviderMock) PdfEngine() (PdfEngine, error) {
// ProcessMock is a mock for the [Process] interface. // ProcessMock is a mock for the [Process] interface.
type ProcessMock struct { type ProcessMock struct {
StartMock func(logger *zap.Logger) error StartMock func(logger *slog.Logger) error
StopMock func(logger *zap.Logger) error StopMock func(logger *slog.Logger) error
HealthyMock func(logger *zap.Logger) bool HealthyMock func(logger *slog.Logger) bool
} }
func (p *ProcessMock) Start(logger *zap.Logger) error { func (p *ProcessMock) Start(logger *slog.Logger) error {
return p.StartMock(logger) return p.StartMock(logger)
} }
func (p *ProcessMock) Stop(logger *zap.Logger) error { func (p *ProcessMock) Stop(logger *slog.Logger) error {
return p.StopMock(logger) return p.StopMock(logger)
} }
func (p *ProcessMock) Healthy(logger *zap.Logger) bool { func (p *ProcessMock) Healthy(logger *slog.Logger) bool {
return p.HealthyMock(logger) return p.HealthyMock(logger)
} }
// ProcessSupervisorMock is a mock for the [ProcessSupervisor] interface. // ProcessSupervisorMock is a mock for the [ProcessSupervisor] interface.
type ProcessSupervisorMock struct { type ProcessSupervisorMock struct {
LaunchMock func() error LaunchMock func() error
ShutdownMock func() error ShutdownMock func() error
HealthyMock func() bool HealthyMock func() bool
RunMock func(ctx context.Context, logger *zap.Logger, task func() error) error RunMock func(ctx context.Context, logger *slog.Logger, task func() error) error
ReqQueueSizeMock func() int64 ReqQueueSizeMock func() int64
RestartsCountMock func() int64 RestartsCountMock func() int64
ActiveTasksCountMock func() int64
ConversionsSinceRestartMock func() int64
} }
func (s *ProcessSupervisorMock) Launch() error { func (s *ProcessSupervisorMock) Launch() error {
@@ -138,7 +184,7 @@ func (s *ProcessSupervisorMock) Healthy() bool {
return s.HealthyMock() return s.HealthyMock()
} }
func (s *ProcessSupervisorMock) Run(ctx context.Context, logger *zap.Logger, task func() error) error { func (s *ProcessSupervisorMock) Run(ctx context.Context, logger *slog.Logger, task func() error) error {
return s.RunMock(ctx, logger, task) return s.RunMock(ctx, logger, task)
} }
@@ -150,13 +196,12 @@ func (s *ProcessSupervisorMock) RestartsCount() int64 {
return s.RestartsCountMock() return s.RestartsCountMock()
} }
// LoggerProviderMock is a mock for the [LoggerProvider] interface. func (s *ProcessSupervisorMock) ActiveTasksCount() int64 {
type LoggerProviderMock struct { return s.ActiveTasksCountMock()
LoggerMock func(mod Module) (*zap.Logger, error)
} }
func (provider *LoggerProviderMock) Logger(mod Module) (*zap.Logger, error) { func (s *ProcessSupervisorMock) ConversionsSinceRestart() int64 {
return provider.LoggerMock(mod) return s.ConversionsSinceRestartMock()
} }
// MetricsProviderMock is a mock for the [MetricsProvider] interface. // MetricsProviderMock is a mock for the [MetricsProvider] interface.
@@ -194,7 +239,6 @@ var (
_ PdfEngineProvider = (*PdfEngineProviderMock)(nil) _ PdfEngineProvider = (*PdfEngineProviderMock)(nil)
_ Process = (*ProcessMock)(nil) _ Process = (*ProcessMock)(nil)
_ ProcessSupervisor = (*ProcessSupervisorMock)(nil) _ ProcessSupervisor = (*ProcessSupervisorMock)(nil)
_ LoggerProvider = (*LoggerProviderMock)(nil)
_ MetricsProvider = (*MetricsProviderMock)(nil) _ MetricsProvider = (*MetricsProviderMock)(nil)
_ MkdirAll = (*MkdirAllMock)(nil) _ MkdirAll = (*MkdirAllMock)(nil)
_ PathRename = (*PathRenameMock)(nil) _ PathRename = (*PathRenameMock)(nil)

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"go.uber.org/zap"
) )
var ( var (
@@ -28,6 +27,18 @@ var (
// ErrPdfEncryptionNotSupported is returned when encryption // ErrPdfEncryptionNotSupported is returned when encryption
// is not supported by the PDF engine. // is not supported by the PDF engine.
ErrPdfEncryptionNotSupported = errors.New("encryption not supported") ErrPdfEncryptionNotSupported = errors.New("encryption not supported")
// ErrPdfStampSourceNotSupported is returned when a stamp source type
// is not supported by the PDF engine.
ErrPdfStampSourceNotSupported = errors.New("stamp source not supported")
// 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 // PdfEngineInvalidArgsError represents an error returned by a PDF engine when
@@ -49,6 +60,33 @@ func NewPdfEngineInvalidArgs(engine, msg string) error {
return &PdfEngineInvalidArgsError{engine, msg} return &PdfEngineInvalidArgsError{engine, msg}
} }
const (
// StampSourceText represents a text-based stamp source.
StampSourceText string = "text"
// StampSourceImage represents an image-based stamp source.
StampSourceImage string = "image"
// StampSourcePDF represents a PDF-based stamp source.
StampSourcePDF string = "pdf"
)
// Stamp gathers the data required to apply a watermark or stamp to a PDF.
type Stamp struct {
// Source is one of "text", "image", or "pdf".
Source string
// Expression is the text content (for text source) or file path (for
// image/pdf source).
Expression string
// Pages is the optional page range to apply the stamp to.
Pages string
// Options holds engine-specific styling options.
Options map[string]string
}
const ( const (
// SplitModeIntervals represents a mode where a PDF is split at specific // SplitModeIntervals represents a mode where a PDF is split at specific
// intervals. // intervals.
@@ -109,6 +147,117 @@ type PdfFormats struct {
PdfUa bool 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 {
Title string `json:"title"`
Page int `json:"page"`
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 // PdfEngine provides an interface for operations on PDFs. Implementations
// can use various tools like PDFtk, or implement functionality directly in // can use various tools like PDFtk, or implement functionality directly in
// Go. // Go.
@@ -117,36 +266,75 @@ type PdfFormats struct {
type PdfEngine interface { type PdfEngine interface {
// Merge combines multiple PDFs into a single PDF. The resulting page order // Merge combines multiple PDFs into a single PDF. The resulting page order
// is determined by the order of files provided in inputPaths. // is determined by the order of files provided in inputPaths.
Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
// Split splits a given PDF file. // Split splits a given PDF file.
Split(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) Split(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
// Flatten merges existing annotation appearances with page content, // Flatten merges existing annotation appearances with page content,
// effectively deleting the original annotations. This process can flatten // effectively deleting the original annotations. This process can flatten
// forms as well as forms share a relationship with annotations. Note that // forms as well as forms share a relationship with annotations. Note that
// this operation is irreversible. // this operation is irreversible.
Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error
// Convert transforms a given PDF to the specified formats defined in // Convert transforms a given PDF to the specified formats defined in
// PdfFormats. If no format, it does nothing. // PdfFormats. If no format, it does nothing.
Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error Convert(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
// ReadMetadata extracts the metadata of a given PDF file. // ReadMetadata extracts the metadata of a given PDF file.
ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
// PageCount returns the number of pages in a PDF file.
PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
// WriteMetadata writes the metadata into a given PDF file. // WriteMetadata writes the metadata into a given PDF file.
WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
// Encrypt adds password protection to a PDF file. // ReadBookmarks reads the document outline (bookmarks) of a PDF file.
// The userPassword is required to open the document. ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error)
// The ownerPassword provides full access to the document.
// If the ownerPassword is empty, it defaults to the userPassword. // WriteBookmarks adds a document outline (bookmarks) to a PDF file.
Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error // 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 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 // EmbedFiles embeds files into a PDF. All files are embedded as file attachments
// without modifying the main PDF content. // without modifying the main PDF content.
EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error // TODO: attachments instead? Rename the route?
EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
// EmbedFilesMetadata sets metadata (such as MIME type and AFRelationship)
// on already-embedded files in a PDF. The metadata map is keyed by
// filename, with each value being a map of property names to values
// (e.g., "mimeType" and "relationship").
EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error
// Watermark applies a watermark (behind page content) to a PDF file.
Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
// Stamp applies a stamp (on top of page content) to a PDF file.
Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
// 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]. // PdfEngineProvider offers an interface to instantiate a [PdfEngine].

View File

@@ -0,0 +1,48 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/semconv/bench_test.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
package semconv
import (
"net/http"
"net/url"
"testing"
"go.opentelemetry.io/otel/attribute"
)
var benchHTTPServerRequestResults []attribute.KeyValue
// BenchmarkHTTPServerRequest allows comparison between different version of the HTTP server.
// To use an alternative start this test with OTEL_SEMCONV_STABILITY_OPT_IN set to the
// version under test.
func BenchmarkHTTPServerRequest(b *testing.B) {
// Request was generated from TestHTTPServerRequest request.
req := &http.Request{
Method: http.MethodGet,
URL: &url.URL{
Path: "/",
},
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
Header: http.Header{
"User-Agent": []string{"Go-http-client/1.1"},
"Accept-Encoding": []string{"gzip"},
},
Body: http.NoBody,
Host: "127.0.0.1:39093",
RemoteAddr: "127.0.0.1:38738",
RequestURI: "/",
}
serv := NewHTTPServer(nil)
b.ReportAllocs()
b.ResetTimer()
for range b.N {
benchHTTPServerRequestResults = serv.RequestTraceAttrs("", req, RequestTraceAttrsOpts{})
}
}

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