Compare commits

...

44 Commits

Author SHA1 Message Date
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
92 changed files with 5136 additions and 1078 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -85,6 +85,15 @@ If a change violates backward compatibility, flag it as a breaking change in the
- No panics in production code paths.
- Validate input defensively.
### Error messages
Client- and operator-facing error messages state what failed, why when non-obvious, and how to fix it when a fix exists. Internal errors (the wrapped `fmt.Errorf` chains that only reach logs) are exempt; keep them precise and technical.
- Client (HTTP response body): name the offending form field and its valid values. Never return a bare `http.StatusText()`.
- Operator (startup, `Provision`, `Validate`): name the environment variable or flag to set, plus the path or value checked.
- Security and filtering errors stay generic for clients. Don't reveal allow/deny lists or private-IP policy. Log the specific reason for operators.
- No hedging ("while others may have failed"). No raw `os.Stat` or exec output in the human-facing remedy.
### Logging
Use `gotenberg.Logger(mod)` to get the module's slog logger during `Provision()`. All log calls must be context-aware: `logger.DebugContext(ctx, msg)`, `logger.InfoContext(ctx, msg)`, `logger.ErrorContext(ctx, msg)`. This propagates trace/span IDs into structured logs when OpenTelemetry is active.

View File

@@ -65,6 +65,7 @@ LOG_LEVEL=info
LOG_FIELDS_PREFIX=
LOG_STD_FORMAT=auto
LOG_STD_ENABLE_GCP_FIELDS=false
LOG_STD_LEVEL_CASE=lower
PDFENGINES_DISABLE_ROUTES=false
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
@@ -80,6 +81,7 @@ PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
PDFENGINES_EMBED_ENGINES=qpdf,pdfcpu
PDFENGINES_EMBED_METADATA_ENGINES=qpdf
PDFENGINES_FACTUR_X_ENGINES=qpdf
PROMETHEUS_NAMESPACE=gotenberg
PROMETHEUS_COLLECT_INTERVAL=1s
PROMETHEUS_DISABLE_ROUTE_TELEMETRY=true
@@ -156,6 +158,7 @@ NO_CONCURRENCY=false
# stamp
# pdfengines-rotate
# rotate
# factur-x
# pdfengines-bookmarks
# bookmarks
# prometheus-metrics

View File

@@ -88,7 +88,7 @@ RUN apt-get update -qq \
WORKDIR /downloads
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.2.0/unoconv -o unoconverter \
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.3.0/unoconv -o unoconverter \
&& chmod +x unoconverter
RUN curl -o pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" \
@@ -125,6 +125,7 @@ RUN groupadd --gid "$GOTENBERG_USER_GID" gotenberg \
RUN apt-get update -qq \
&& apt-get upgrade -yqq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
ca-certificates \
tini \
# Many users rely on curl for Docker health checks.
curl \
@@ -189,6 +190,19 @@ ENV QPDF_BIN_PATH=/usr/bin/qpdf
ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool
ENV PDFCPU_BIN_PATH=/usr/bin/pdfcpu
# Capture backing-binary versions at build time so the running process reports
# them on traces without spawning the binaries at startup or per request.
# See pkg/gotenberg/buildversions.go. Chromium and LibreOffice are captured in
# the variant stages below, where they are installed.
ENV GOTENBERG_VERSIONS_DIR_PATH=/opt/gotenberg/versions
COPY --link build/capture-version.sh /opt/gotenberg/capture-version.sh
RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" qpdf "$QPDF_BIN_PATH" --version \
&& bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" exiftool "$EXIFTOOL_BIN_PATH" -ver \
&& bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" pdftk "$PDFTK_BIN_PATH" --version \
&& bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" pdfcpu "$PDFCPU_BIN_PATH" version
# OpenTelemetry defaults (noop - no telemetry overhead unless explicitly enabled).
ENV OTEL_TRACES_EXPORTER=none
ENV OTEL_METRICS_EXPORTER=none
@@ -268,6 +282,10 @@ ENV CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
# Capture Chromium and LibreOffice versions now that both are installed.
RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" chromium "$CHROMIUM_BIN_PATH" --version \
&& bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" libreoffice-api "$LIBREOFFICE_BIN_PATH" --version
USER gotenberg
WORKDIR /home/gotenberg
@@ -328,6 +346,9 @@ ENV CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data
# No LibreOffice in this variant; override the default to use all available engines.
ENV PDFENGINES_CONVERT_ENGINES=
# Capture the Chromium version now that it is installed.
RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" chromium "$CHROMIUM_BIN_PATH" --version
USER gotenberg
WORKDIR /home/gotenberg
@@ -382,6 +403,9 @@ COPY --link --from=downloader-stage /downloads/unoconverter /usr/bin/unoconverte
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
# Capture the LibreOffice version now that it is installed.
RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" libreoffice-api "$LIBREOFFICE_BIN_PATH" --version
USER gotenberg
WORKDIR /home/gotenberg

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

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

View File

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

View File

@@ -66,6 +66,7 @@ services:
- "--log-fields-prefix=${LOG_FIELDS_PREFIX}"
- "--log-std-format=${LOG_STD_FORMAT}"
- "--log-std-enable-gcp-fields=${LOG_STD_ENABLE_GCP_FIELDS}"
- "--log-std-level-case=${LOG_STD_LEVEL_CASE}"
- "--pdfengines-merge-engines=${PDFENGINES_MERGE_ENGINES}"
- "--pdfengines-split-engines=${PDFENGINES_SPLIT_ENGINES}"
- "--pdfengines-flatten-engines=${PDFENGINES_FLATTEN_ENGINES}"
@@ -80,6 +81,7 @@ services:
- "--pdfengines-rotate-engines=${PDFENGINES_ROTATE_ENGINES}"
- "--pdfengines-embed-engines=${PDFENGINES_EMBED_ENGINES}"
- "--pdfengines-embed-metadata-engines=${PDFENGINES_EMBED_METADATA_ENGINES}"
- "--pdfengines-factur-x-engines=${PDFENGINES_FACTUR_X_ENGINES}"
- "--pdfengines-disable-routes=${PDFENGINES_DISABLE_ROUTES}"
- "--prometheus-namespace=${PROMETHEUS_NAMESPACE}"
- "--prometheus-collect-interval=${PROMETHEUS_COLLECT_INTERVAL}"

2
go.mod
View File

@@ -18,7 +18,7 @@ require (
github.com/moby/moby/api v1.54.2
github.com/moby/moby/client v0.4.1
github.com/prometheus/client_golang v1.23.2
github.com/shirou/gopsutil/v4 v4.26.4
github.com/shirou/gopsutil/v4 v4.26.5
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.42.0

4
go.sum
View File

@@ -213,8 +213,8 @@ github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4Ul
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY=
github.com/shirou/gopsutil/v4 v4.26.4/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM=
github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,10 @@ import (
"sync"
"sync/atomic"
"time"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
// ErrProcessAlreadyRestarting happens if the [ProcessSupervisor] is trying
@@ -76,6 +80,10 @@ type ProcessSupervisor interface {
// ActiveTasksCount returns the current number of active tasks.
ActiveTasksCount() int64
// ConversionsSinceRestart returns the number of tasks handled since the
// last process (re)start.
ConversionsSinceRestart() int64
}
// healthCheckCacheTTL caches successful health probe results so kubelet-
@@ -97,6 +105,7 @@ const healthFailureThreshold = 2
type processSupervisor struct {
logger *slog.Logger
engine string
process Process
maxReqLimit int64
maxQueueSize int64
@@ -128,14 +137,21 @@ type processSupervisor struct {
idleStopChan chan struct{} // signal to stop the idle ticker goroutine
}
// NewProcessSupervisor initializes a new [ProcessSupervisor].
func NewProcessSupervisor(logger *slog.Logger, process Process, maxReqLimit, maxQueueSize, maxConcurrency int64, idleShutdownTimeout time.Duration) ProcessSupervisor {
// NewProcessSupervisor initializes a new [ProcessSupervisor]. engine names the
// managed process (for example "chromium" or "libreoffice") and prefixes the
// telemetry sub-spans; an empty engine falls back to "process".
func NewProcessSupervisor(logger *slog.Logger, engine string, process Process, maxReqLimit, maxQueueSize, maxConcurrency int64, idleShutdownTimeout time.Duration) ProcessSupervisor {
if maxConcurrency < 1 {
maxConcurrency = 1
}
if engine == "" {
engine = "process"
}
b := &processSupervisor{
logger: logger,
engine: engine,
process: process,
semaphore: make(chan struct{}, maxConcurrency),
maxReqLimit: maxReqLimit,
@@ -267,6 +283,20 @@ func (s *processSupervisor) recentlyHealthy() bool {
}
func (s *processSupervisor) Run(ctx context.Context, logger *slog.Logger, task func() error) error {
// Time spent before the task body runs: queueing, slot acquisition, lazy
// launch, and health checks. Ended once, when the task is about to execute.
_, queueSpan := Tracer().Start(ctx, s.engine+".queue.wait",
trace.WithSpanKind(trace.SpanKindInternal),
)
queueWaitDone := false
endQueueWait := func() {
if !queueWaitDone {
queueWaitDone = true
queueSpan.End()
}
}
defer endQueueWait()
// Atomically check and increment the queue size to avoid the TOCTOU race
// originally reported in https://github.com/gotenberg/gotenberg/issues/951.
for {
@@ -315,6 +345,7 @@ func (s *processSupervisor) Run(ctx context.Context, logger *slog.Logger, task f
return err
}
endQueueWait()
err := s.runWithDeadline(ctx, task)
if s.maybeRestartAfterTask(logger) {
@@ -453,8 +484,8 @@ func (s *processSupervisor) ensureStarted(ctx context.Context) error {
return nil
}
err := s.runWithDeadline(ctx, func() error {
return s.Launch()
err := s.tracedLaunch(ctx, "first_start", func() error {
return s.runWithDeadline(ctx, s.Launch)
})
if err != nil {
return fmt.Errorf("process first start: %w", err)
@@ -463,6 +494,27 @@ func (s *processSupervisor) ensureStarted(ctx context.Context) error {
return nil
}
// tracedLaunch wraps a process (re)start in an <engine>.process.start span,
// tagged with the reason that triggered it. The eager restart after the maximum
// request limit runs on a background context, so its span is a detached root.
func (s *processSupervisor) tracedLaunch(ctx context.Context, reason string, launch func() error) error {
_, span := Tracer().Start(ctx, s.engine+".process.start",
trace.WithSpanKind(trace.SpanKindInternal),
trace.WithAttributes(attribute.String("gotenberg.process.start.reason", reason)),
)
defer span.End()
err := launch()
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
span.SetStatus(codes.Ok, "")
return nil
}
// ensureHealthy checks the underlying process health and triggers a
// synchronous restart if the process is unhealthy. Skips the check if a
// restart is already in progress.
@@ -473,7 +525,7 @@ func (s *processSupervisor) ensureHealthy(ctx context.Context) error {
s.logger.DebugContext(context.Background(), "process is unhealthy, cannot handle task, restarting...")
if err := s.doRestart(ctx); err != nil {
if err := s.doRestart(ctx, "unhealthy"); err != nil {
return fmt.Errorf("process restart before task: %w", err)
}
@@ -496,7 +548,7 @@ func (s *processSupervisor) maybeRestartAfterTask(logger *slog.Logger) bool {
s.logger.DebugContext(context.Background(), "max request limit reached, restarting eagerly...")
go func() {
restartErr := s.doRestartLocked(context.Background())
restartErr := s.doRestartLocked(context.Background(), "max_requests")
s.restartMutex.Unlock()
if restartErr != nil {
s.logger.ErrorContext(context.Background(), fmt.Sprintf("process restart after task: %v", restartErr))
@@ -510,15 +562,15 @@ func (s *processSupervisor) maybeRestartAfterTask(logger *slog.Logger) bool {
// doRestart coordinates a process restart, draining all active concurrent
// tasks before stopping and restarting the process.
func (s *processSupervisor) doRestart(ctx context.Context) error {
func (s *processSupervisor) doRestart(ctx context.Context, reason string) error {
s.restartMutex.Lock()
defer s.restartMutex.Unlock()
return s.doRestartLocked(ctx)
return s.doRestartLocked(ctx, reason)
}
// doRestartLocked performs the restart drain logic. The caller must hold restartMutex.
func (s *processSupervisor) doRestartLocked(ctx context.Context) error {
func (s *processSupervisor) doRestartLocked(ctx context.Context, reason string) error {
s.isRestarting.Store(true)
defer s.isRestarting.Store(false)
@@ -538,8 +590,8 @@ func (s *processSupervisor) doRestartLocked(ctx context.Context) error {
}
}
err := s.runWithDeadline(ctx, func() error {
return s.restart()
err := s.tracedLaunch(ctx, reason, func() error {
return s.runWithDeadline(ctx, s.restart)
})
for range acquired {
@@ -577,6 +629,13 @@ func (s *processSupervisor) ActiveTasksCount() int64 {
return s.activeTasks.Load()
}
// ConversionsSinceRestart returns the number of tasks handled since the last
// process (re)start. reqCounter is reset to zero on every restart and idle
// shutdown.
func (s *processSupervisor) ConversionsSinceRestart() int64 {
return s.reqCounter.Load()
}
// Interface guards.
var (
_ ProcessSupervisor = (*processSupervisor)(nil)

View File

@@ -45,7 +45,7 @@ func TestProcessSupervisor_Launch(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
if tc.firstStartSet {
ps.firstStart.Store(true)
}
@@ -93,7 +93,7 @@ func TestProcessSupervisor_Shutdown(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0)
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0)
err := ps.Shutdown()
if !tc.expectError && err != nil {
@@ -145,7 +145,7 @@ func TestProcessSupervisor_restart(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
err := ps.restart()
@@ -201,7 +201,7 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
if tc.initiallyStarted {
ps.firstStart.Store(true)
}
@@ -229,7 +229,7 @@ func TestProcessSupervisor_Healthy_ConsecutiveFailures(t *testing.T) {
HealthyMock: func(_ *slog.Logger) bool { return processHealthy.Load() },
}
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
ps.firstStart.Store(true)
processHealthy.Store(false)
@@ -272,7 +272,7 @@ func TestProcessSupervisor_Healthy_CachesPositiveResult(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
ps.firstStart.Store(true)
for range 5 {
@@ -302,7 +302,7 @@ func TestProcessSupervisor_Healthy_DoesNotCacheNegativeResult(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
ps.firstStart.Store(true)
processHealthy.Store(false)
@@ -489,7 +489,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit, tc.maxQueueSize, 1, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, "test", process, tc.maxReqLimit, tc.maxQueueSize, 1, 0).(*processSupervisor)
if tc.initiallyStarted {
ps.firstStart.Store(true)
}
@@ -573,7 +573,7 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
},
} {
t.Run(tc.scenario, func(t *testing.T) {
ps := NewProcessSupervisor(slog.New(slog.DiscardHandler), new(ProcessMock), 0, 0, 1, 0).(*processSupervisor)
ps := NewProcessSupervisor(slog.New(slog.DiscardHandler), "test", new(ProcessMock), 0, 0, 1, 0).(*processSupervisor)
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
if tc.ctxDone {
@@ -607,7 +607,7 @@ func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
return true
},
}
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, 0).(*processSupervisor)
// Simulating a lock.
ps.semaphore <- struct{}{}
@@ -668,7 +668,7 @@ func TestProcessSupervisor_QueueSizeCAS(t *testing.T) {
maxQueueSize := int64(50)
// maxConcurrency=1 so all goroutines block on the semaphore, exercising queue logic.
ps := NewProcessSupervisor(logger, process, 0, maxQueueSize, 1, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, "test", process, 0, maxQueueSize, 1, 0).(*processSupervisor)
// Simulating a lock so that all goroutines queue up.
ps.semaphore <- struct{}{}
@@ -722,7 +722,7 @@ func TestProcessSupervisor_QueueSizeIncludesActiveTasks(t *testing.T) {
}
// maxQueueSize=1, maxConcurrency=1: only one request at a time.
ps := NewProcessSupervisor(logger, process, 0, 1, 1, 0)
ps := NewProcessSupervisor(logger, "test", process, 0, 1, 1, 0)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -811,7 +811,7 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, 0).(*processSupervisor)
ps.restartsCounter.Store(tc.initialRestartsCount)
for i := 0; i < tc.restartAttempts; i++ {
@@ -844,7 +844,7 @@ func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
}
maxConcurrency := int64(3)
ps := NewProcessSupervisor(logger, process, 0, 0, maxConcurrency, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, "test", process, 0, 0, maxConcurrency, 0).(*processSupervisor)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -906,7 +906,7 @@ func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) {
}
maxConcurrency := int64(3)
ps := NewProcessSupervisor(logger, process, 3, 0, maxConcurrency, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, "test", process, 3, 0, maxConcurrency, 0).(*processSupervisor)
ps.firstStart.Store(true)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -963,7 +963,7 @@ func TestProcessSupervisor_IdleShutdown(t *testing.T) {
}
idleTimeout := 50 * time.Millisecond
ps := NewProcessSupervisor(logger, process, 0, 0, 1, idleTimeout).(*processSupervisor)
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, idleTimeout).(*processSupervisor)
ctx := context.Background()
err := ps.Run(ctx, logger, func() error {
@@ -1023,7 +1023,7 @@ func TestProcessSupervisor_RetryAfterFailedFirstStart(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, 0).(*processSupervisor)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -1069,7 +1069,7 @@ func TestProcessSupervisor_IdleShutdownSkippedWhenActive(t *testing.T) {
}
idleTimeout := 50 * time.Millisecond
ps := NewProcessSupervisor(logger, process, 0, 0, 1, idleTimeout)
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, idleTimeout)
ctx := context.Background()
go func() {
@@ -1091,3 +1091,70 @@ func TestProcessSupervisor_IdleShutdownSkippedWhenActive(t *testing.T) {
close(taskDone)
}
func TestProcessSupervisor_ConversionsSinceRestart(t *testing.T) {
process := &ProcessMock{
StartMock: func(*slog.Logger) error { return nil },
StopMock: func(*slog.Logger) error { return nil },
HealthyMock: func(*slog.Logger) bool { return true },
}
s := NewProcessSupervisor(slog.New(slog.DiscardHandler), "test", process, 0, 0, 1, 0).(*processSupervisor)
if got := s.ConversionsSinceRestart(); got != 0 {
t.Errorf("expected 0 conversions initially, got %d", got)
}
s.reqCounter.Store(7)
if got := s.ConversionsSinceRestart(); got != 7 {
t.Errorf("expected 7 conversions, got %d", got)
}
if err := s.restart(); err != nil {
t.Fatalf("restart: %v", err)
}
if got := s.ConversionsSinceRestart(); got != 0 {
t.Errorf("expected 0 conversions after restart, got %d", got)
}
}
func TestProcessSupervisor_RunEmitsSubSpans(t *testing.T) {
recorder := newTestSpanRecorder(t)
process := &ProcessMock{
StartMock: func(*slog.Logger) error { return nil },
StopMock: func(*slog.Logger) error { return nil },
HealthyMock: func(*slog.Logger) bool { return true },
}
s := NewProcessSupervisor(slog.New(slog.DiscardHandler), "chromium", process, 0, 0, 1, 0)
err := s.Run(context.Background(), slog.New(slog.DiscardHandler), func() error { return nil })
if err != nil {
t.Fatalf("run: %v", err)
}
var queueWaits, processStarts int
var startReason string
for _, span := range recorder.Ended() {
switch span.Name() {
case "chromium.queue.wait":
queueWaits++
case "chromium.process.start":
processStarts++
for _, kv := range span.Attributes() {
if string(kv.Key) == "gotenberg.process.start.reason" {
startReason = kv.Value.AsString()
}
}
}
}
if queueWaits != 1 {
t.Errorf("expected 1 chromium.queue.wait span, got %d", queueWaits)
}
if processStarts != 1 {
t.Errorf("expected 1 chromium.process.start span (first start), got %d", processStarts)
}
if startReason != "first_start" {
t.Errorf("expected process start reason first_start, got %q", startReason)
}
}

View File

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

View File

@@ -111,7 +111,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
if bodyLimit != 0 && newTotal > bodyLimit {
return WrapError(
fmt.Errorf("body limit reached (> %d)", bodyLimit),
NewSentinelHttpError(http.StatusRequestEntityTooLarge, http.StatusText(http.StatusRequestEntityTooLarge)),
NewSentinelHttpError(http.StatusRequestEntityTooLarge, "The request body exceeds the configured size limit. Increase it with --api-body-limit, or send a smaller request."),
)
}
return nil
@@ -536,6 +536,11 @@ func (ctx *Context) FormData() *FormData {
}
}
// FileCount returns the number of files received in the request.
func (ctx *Context) FileCount() int {
return len(ctx.files)
}
// OriginalFilename returns the original filename associated with a disk path.
// If no mapping exists, it falls back to [filepath.Base].
func (ctx *Context) OriginalFilename(diskPath string) string {

View File

@@ -206,3 +206,19 @@ func TestSanitizeFilename(t *testing.T) {
})
}
}
func TestContext_FileCount(t *testing.T) {
ctx := &Context{}
if got := ctx.FileCount(); got != 0 {
t.Errorf("expected 0 files, got %d", got)
}
ctx.files = map[string]string{
"index.html": "/work/index.html",
"header.html": "/work/header.html",
"styles.css": "/work/styles.css",
}
if got := ctx.FileCount(); got != 3 {
t.Errorf("expected 3 files, got %d", got)
}
}

View File

@@ -26,6 +26,10 @@ const (
// StampFormField represents the form field name for the stamp file.
StampFormField string = "stamp"
// FacturXXmlFormField represents the form field name for the Factur-X CII
// invoice XML file.
FacturXXmlFormField string = "facturxXml"
)
// FormData is a helper for validating and hydrating values from a
@@ -475,13 +479,28 @@ func (form *FormData) Stamp(target *string) *FormData {
return form
}
// FacturXXml binds the absolute path of the uploaded Factur-X CII invoice
// XML. Only a file uploaded with the "facturxXml" field name is included.
func (form *FormData) FacturXXml(target *string) *FormData {
if form.errors != nil {
return form
}
if paths, ok := form.filesByField[FacturXXmlFormField]; ok && len(paths) > 0 {
*target = paths[0]
}
return form
}
// paths bind the absolute paths of form data files, according to a list of
// file extensions, to a string slice variable.
// embeds, watermark, and stamp files are excluded.
// embeds, watermark, stamp, and facturxXml files are excluded.
func (form *FormData) paths(extensions []string, target *[]string) *FormData {
embeds, ok := form.filesByField[EmbedsFormField]
watermarks, wmOk := form.filesByField[WatermarkFormField]
stamps, stOk := form.filesByField[StampFormField]
facturxXmls, fxOk := form.filesByField[FacturXXmlFormField]
// Collect (originalFilename, diskPath) pairs so that we can sort by
// original filename rather than by UUID-based disk name.
@@ -505,6 +524,10 @@ func (form *FormData) paths(extensions []string, target *[]string) *FormData {
continue
}
if fxOk && slices.Contains(facturxXmls, path) {
continue
}
for _, ext := range extensions {
// See https://github.com/gotenberg/gotenberg/issues/228.
if strings.ToLower(filepath.Ext(filename)) == ext {

View File

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

View File

@@ -43,7 +43,7 @@ func ParseError(err error) (int, string) {
}
if errors.Is(err, context.DeadlineExceeded) {
return http.StatusServiceUnavailable, http.StatusText(http.StatusServiceUnavailable)
return http.StatusServiceUnavailable, "The request exceeded the time limit. Increase it with --api-timeout, or reduce the workload."
}
if errors.Is(err, gotenberg.ErrFiltered) {
@@ -51,27 +51,27 @@ func ParseError(err error) (int, string) {
}
if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
return http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests)
return http.StatusTooManyRequests, "The request queue is full. Retry shortly, or raise the limit with --chromium-max-queue-size or --libreoffice-max-queue-size."
}
if errors.Is(err, gotenberg.ErrPdfSplitModeNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues"
return http.StatusBadRequest, "The requested split mode is not supported, or no PDF engine could process it. Valid modes: 'intervals', 'pages'."
}
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues"
return http.StatusBadRequest, "The requested PDF format is not supported, or no PDF engine could apply it. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA."
}
if errors.Is(err, gotenberg.ErrPdfEngineMetadataValueNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested metadata, while others may have failed to convert due to different issues"
return http.StatusBadRequest, "The requested metadata could not be written; ensure values are valid and free of control characters."
}
if errors.Is(err, gotenberg.ErrPdfStampSourceNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested stamp source type, while others may have failed due to different issues"
return http.StatusBadRequest, "The requested stamp source is not supported, or no PDF engine could process it. Valid sources: 'text', 'image', 'pdf'."
}
if errors.Is(err, gotenberg.ErrPdfRotateAngleNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested rotation angle, while others may have failed due to different issues"
return http.StatusBadRequest, "The requested rotation angle is not supported. Valid angles: 90, 180, 270."
}
if invalidArgsError, ok := errors.AsType[*gotenberg.PdfEngineInvalidArgsError](err); ok {

View File

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

View File

@@ -25,8 +25,8 @@ import (
type browser interface {
gotenberg.Process
pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error
screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error
pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions, aggregate *networkAggregate) error
screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions, aggregate *networkAggregate) error
}
type browserArguments struct {
@@ -314,10 +314,10 @@ func (b *chromiumBrowser) Healthy(logger *slog.Logger) bool {
return true
}
func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions, aggregate *networkAggregate) error {
// Note: no error wrapping because it leaks on errors we want to display to
// the end user.
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
return b.do(ctx, logger, url, options.Options, aggregate, chromedp.Tasks{
network.Enable(),
fetch.Enable(),
runtime.Enable(),
@@ -334,16 +334,16 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, out
waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector),
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
// PDF specific.
printToPdfActionFunc(logger, outputPath, options),
printToPdfActionFunc(ctx, logger, outputPath, options),
// Teardown.
page.Close(),
})
}
func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions, aggregate *networkAggregate) error {
// Note: no error wrapping because it leaks on errors we want to display to
// the end user.
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
return b.do(ctx, logger, url, options.Options, aggregate, chromedp.Tasks{
network.Enable(),
fetch.Enable(),
runtime.Enable(),
@@ -367,7 +367,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, u
})
}
func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url string, options Options, tasks chromedp.Tasks) error {
func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url string, options Options, aggregate *networkAggregate, tasks chromedp.Tasks) error {
if !b.isStarted.Load() {
return errors.New("browser not started, cannot handle tasks")
}
@@ -396,6 +396,9 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
taskCtx, taskCancel := chromedp.NewContext(timeoutCtx)
defer taskCancel()
// Accumulate per-conversion network activity for telemetry.
listenForNetworkActivity(taskCtx, aggregate)
// We validate all other requests against our allowed / deny lists.
// If a request does not pass the validation, we make it fail. It also set
// the extra HTTP headers, if any.

View File

@@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"strings"
"sync"
"syscall"
"time"
@@ -101,12 +102,17 @@ type Chromium struct {
supervisor gotenberg.ProcessSupervisor
engine gotenberg.PdfEngine
version string
versionOnce sync.Once
reqsCounter metric.Int64Counter
errsCounter metric.Int64Counter
conversionDurationCounter metric.Float64Histogram
queueWaitDurationCounter metric.Float64Histogram
pdfOutputSizeCounter metric.Int64Histogram
imageOutputSizeCounter metric.Int64Histogram
networkRequestsCounter metric.Int64Counter
networkBytesCounter metric.Int64Histogram
}
// Options are the common options for all conversions.
@@ -484,12 +490,12 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
binPath, ok := os.LookupEnv("CHROMIUM_BIN_PATH")
if !ok {
return errors.New("CHROMIUM_BIN_PATH environment variable is not set")
return errors.New("CHROMIUM_BIN_PATH environment variable is not set; set it to the absolute path of the Chromium or Chrome binary")
}
hyphenDataDirPath, ok := os.LookupEnv("CHROMIUM_HYPHEN_DATA_DIR_PATH")
if !ok {
return errors.New("CHROMIUM_HYPHEN_DATA_DIR_PATH environment variable is not set")
return errors.New("CHROMIUM_HYPHEN_DATA_DIR_PATH environment variable is not set; set it to the absolute path of the Chromium hyphenation data directory (it ships in the Gotenberg image)")
}
mod.args = browserArguments{
@@ -517,7 +523,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
// Process.
mod.browser = newChromiumBrowser(mod.args)
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"), mod.maxConcurrency, flags.MustDuration("chromium-idle-shutdown-timeout"))
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, "chromium", mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"), mod.maxConcurrency, flags.MustDuration("chromium-idle-shutdown-timeout"))
// PDF Engine.
provider, err := ctx.Module(new(gotenberg.PdfEngineProvider))
@@ -633,6 +639,24 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
return fmt.Errorf("create chromium.image.output.size histogram: %w", err)
}
mod.networkRequestsCounter, err = meter.Int64Counter(
"chromium.network.requests.total",
metric.WithDescription("Total number of network requests made during Chromium conversions"),
metric.WithUnit("{request}"),
)
if err != nil {
return fmt.Errorf("create chromium.network.requests.total counter: %w", err)
}
mod.networkBytesCounter, err = meter.Int64Histogram(
"chromium.network.bytes",
metric.WithDescription("Bytes fetched over the network during a Chromium conversion"),
metric.WithUnit("By"),
)
if err != nil {
return fmt.Errorf("create chromium.network.bytes histogram: %w", err)
}
return nil
}
@@ -644,12 +668,12 @@ func (mod *Chromium) Validate() error {
_, err := os.Stat(mod.args.binPath)
if os.IsNotExist(err) {
return fmt.Errorf("chromium binary path does not exist: %w", err)
return fmt.Errorf("Chromium binary does not exist at %q; check the CHROMIUM_BIN_PATH environment variable: %w", mod.args.binPath, err)
}
_, err = os.Stat(mod.args.hyphenDataDirPath)
if os.IsNotExist(err) {
return fmt.Errorf("chromium hyphen-data directory path does not exist: %w", err)
return fmt.Errorf("Chromium hyphenation data directory does not exist at %q; check the CHROMIUM_HYPHEN_DATA_DIR_PATH environment variable (it ships in the Gotenberg image): %w", mod.args.hyphenDataDirPath, err)
}
return nil
@@ -697,19 +721,46 @@ func (mod *Chromium) Stop(ctx context.Context) error {
// Debug returns additional debug data.
func (mod *Chromium) Debug() map[string]any {
debug := make(map[string]any)
return map[string]any{"version": mod.detectVersion()}
}
cmd := exec.Command(mod.args.binPath, "--version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
// detectVersion resolves the Chromium version once, preferring the value
// captured at image build time so it never spawns Chromium at runtime. It falls
// back to running chromium --version for local or non-Docker builds.
func (mod *Chromium) detectVersion() string {
mod.versionOnce.Do(func() {
if v, ok := gotenberg.BuildVersion("chromium"); ok {
mod.version = v
return
}
output, err := cmd.Output()
if err != nil {
debug["version"] = err.Error()
return debug
cmd := exec.Command(mod.args.binPath, "--version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
output, err := cmd.Output()
if err != nil {
mod.version = err.Error()
return
}
mod.version = strings.TrimSpace(string(output))
})
return mod.version
}
// spanAttrs returns the client-span attributes for a Chromium invocation: the
// server address and the Chromium version, plus any extra attributes. The
// version rides on every conversion span so a trace records which Chromium
// rendered the document.
func (mod *Chromium) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue {
attrs := make([]attribute.KeyValue, 0, 2+len(extra))
attrs = append(attrs, semconv.ServerAddress(mod.args.binPath))
if v := mod.detectVersion(); v != "" {
attrs = append(attrs, attribute.String("gotenberg.chromium.version", v))
}
debug["version"] = strings.TrimSpace(string(output))
return debug
return append(attrs, extra...)
}
// Metrics returns the metrics.
@@ -799,19 +850,32 @@ func (mod *Chromium) Routes() ([]api.Route, error) {
}
// Pdf converts a URL to PDF.
//
//nolint:dupl
func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
// Read input attributes before Start rebinds ctx to the span context, which
// would shadow the underlying [api.Context].
inputAttrs := conversionInputAttrs(ctx, url)
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Pdf",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)),
trace.WithAttributes(mod.spanAttrs()...),
)
defer span.End()
span.SetAttributes(inputAttrs...)
span.SetAttributes(
attribute.Int64("gotenberg.queue.depth_at_arrival", mod.supervisor.ReqQueueSize()),
attribute.Int64("gotenberg.conversions_since_last_restart", mod.supervisor.ConversionsSinceRestart()),
)
start := time.Now()
var conversionStart time.Time
aggregate := newNetworkAggregate()
err := mod.supervisor.Run(ctx, logger, func() error {
conversionStart = time.Now()
return mod.browser.pdf(ctx, logger, url, outputPath, options)
return mod.browser.pdf(ctx, logger, url, outputPath, options, aggregate)
})
end := time.Now()
@@ -824,21 +888,12 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
status = "error"
}
reason := "unknown"
switch {
case errors.Is(err, context.DeadlineExceeded):
reason = "timeout"
case errors.Is(err, context.Canceled):
reason = "context_cancelled"
case errors.Is(err, ErrInvalidHttpStatusCode) || errors.Is(err, ErrInvalidResourceHttpStatusCode) || errors.Is(err, ErrLoadingFailed) || errors.Is(err, ErrResourceLoadingFailed) || errors.Is(err, ErrInvalidEvaluationExpression) || errors.Is(err, ErrInvalidSelectorQuery):
reason = "invalid_input"
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) || errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
reason = "chromium_unavailable"
}
reason := chromiumErrorType(err, "chromium_unavailable")
mod.errsCounter.Add(ctx, 1, metric.WithAttributes(
attribute.String("reason", reason),
))
gotenberg.SpanErrorType(span, reason)
}
if !conversionStart.IsZero() {
@@ -862,9 +917,12 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
attribute.String("status", status),
))
mod.recordNetwork(ctx, span, aggregate)
if err == nil {
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
mod.pdfOutputSizeCounter.Record(ctx, fileInfo.Size())
span.SetAttributes(attribute.Int64("gotenberg.conversion.output.bytes", fileInfo.Size()))
}
span.SetStatus(codes.Ok, "")
@@ -876,19 +934,28 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
return err
}
// Screenshot captures a screenshot from a URL.
//
//nolint:dupl
func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Screenshot",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)),
trace.WithAttributes(mod.spanAttrs()...),
)
defer span.End()
span.SetAttributes(
attribute.Int64("gotenberg.queue.depth_at_arrival", mod.supervisor.ReqQueueSize()),
attribute.Int64("gotenberg.conversions_since_last_restart", mod.supervisor.ConversionsSinceRestart()),
)
start := time.Now()
var conversionStart time.Time
aggregate := newNetworkAggregate()
err := mod.supervisor.Run(ctx, logger, func() error {
conversionStart = time.Now()
return mod.browser.screenshot(ctx, logger, url, outputPath, options)
return mod.browser.screenshot(ctx, logger, url, outputPath, options, aggregate)
})
end := time.Now()
@@ -901,23 +968,12 @@ func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, o
status = "error"
}
reason := "unknown"
switch {
case errors.Is(err, context.DeadlineExceeded):
reason = "timeout"
case errors.Is(err, context.Canceled):
reason = "context_cancelled"
case errors.Is(err, ErrInvalidHttpStatusCode) || errors.Is(err, ErrInvalidResourceHttpStatusCode) || errors.Is(err, ErrLoadingFailed) || errors.Is(err, ErrResourceLoadingFailed) || errors.Is(err, ErrInvalidEvaluationExpression) || errors.Is(err, ErrInvalidSelectorQuery):
reason = "invalid_input"
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded):
reason = "chromium_maximum_queue_size_exceeded"
case errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
reason = "chromium_unavailable"
}
reason := chromiumErrorType(err, "chromium_maximum_queue_size_exceeded")
mod.errsCounter.Add(ctx, 1, metric.WithAttributes(
attribute.String("reason", reason),
))
gotenberg.SpanErrorType(span, reason)
}
if !conversionStart.IsZero() {
@@ -941,6 +997,8 @@ func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, o
attribute.String("status", status),
))
mod.recordNetwork(ctx, span, aggregate)
if err == nil {
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
mod.imageOutputSizeCounter.Record(ctx, fileInfo.Size())
@@ -955,6 +1013,86 @@ func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, o
return err
}
// recordNetwork lifts per-conversion network aggregates onto the span and the
// network metrics. Counts are dimensioned by outcome and bytes feed a
// histogram; both are recorded with the conversion context so the SDK attaches
// trace exemplars. The heaviest resource URL is redacted before it lands on the
// span event.
func (mod *Chromium) recordNetwork(ctx context.Context, span trace.Span, aggregate *networkAggregate) {
if aggregate == nil {
return
}
stats := aggregate.snapshot()
span.SetAttributes(
attribute.Int64("gotenberg.chromium.resources.count", stats.requestCount),
attribute.Int64("gotenberg.chromium.resources.bytes_total", stats.bytesTotal),
attribute.Int64("gotenberg.chromium.resources.failed_count", stats.failedCount),
attribute.Int64("gotenberg.chromium.resources.unique_origins", stats.uniqueOrigins),
)
if stats.heaviestURL != "" {
span.AddEvent("chromium.heaviest_resource", trace.WithAttributes(
attribute.String("url", gotenberg.RedactURL(stats.heaviestURL)),
attribute.Int64("bytes", stats.heaviestBytes),
))
}
if ok := stats.requestCount - stats.failedCount; ok > 0 {
mod.networkRequestsCounter.Add(ctx, ok, metric.WithAttributes(attribute.String("outcome", "ok")))
}
if stats.failedCount > 0 {
mod.networkRequestsCounter.Add(ctx, stats.failedCount, metric.WithAttributes(attribute.String("outcome", "failed")))
}
mod.networkBytesCounter.Record(ctx, stats.bytesTotal)
}
// conversionInputAttrs derives low-cardinality input attributes for a
// conversion span: the number of received files (when ctx is an [api.Context])
// and the size of the local HTML input (when url is a file:// URL). Remote URL
// conversions yield no html.bytes.
func conversionInputAttrs(ctx context.Context, url string) []attribute.KeyValue {
var attrs []attribute.KeyValue
if apiCtx, ok := ctx.(*api.Context); ok {
attrs = append(attrs, attribute.Int("gotenberg.conversion.input.files.count", apiCtx.FileCount()))
}
if after, ok := strings.CutPrefix(url, "file://"); ok {
if info, err := os.Stat(after); err == nil {
attrs = append(attrs, attribute.Int64("gotenberg.conversion.input.html.bytes", info.Size()))
}
}
return attrs
}
// chromiumErrorType maps a conversion error to chromium's bounded reason value,
// reused as the span error.type. queueReason preserves the historical,
// route-specific label for a saturated queue: Pdf collapses it into
// "chromium_unavailable", whereas Screenshot keeps
// "chromium_maximum_queue_size_exceeded". Generic failures fall back to
// [gotenberg.ClassifyError].
func chromiumErrorType(err error, queueReason string) string {
switch {
case errors.Is(err, ErrInvalidHttpStatusCode),
errors.Is(err, ErrInvalidResourceHttpStatusCode),
errors.Is(err, ErrLoadingFailed),
errors.Is(err, ErrResourceLoadingFailed),
errors.Is(err, ErrInvalidEvaluationExpression),
errors.Is(err, ErrInvalidSelectorQuery):
return gotenberg.ErrorTypeInvalidInput
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded):
return queueReason
case errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
return "chromium_unavailable"
default:
return gotenberg.ClassifyError(err)
}
}
// Interface guards.
var (
_ gotenberg.Module = (*Chromium)(nil)

View File

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

View File

@@ -23,6 +23,26 @@ import (
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// listenForNetworkActivity accumulates per-conversion network activity into
// aggregate from the always-on Network domain events. It is a no-op when
// aggregate is nil.
func listenForNetworkActivity(ctx context.Context, aggregate *networkAggregate) {
if aggregate == nil {
return
}
chromedp.ListenTarget(ctx, func(ev any) {
switch e := ev.(type) {
case *network.EventResponseReceived:
aggregate.onResponseReceived(e)
case *network.EventLoadingFinished:
aggregate.onLoadingFinished(e)
case *network.EventLoadingFailed:
aggregate.onLoadingFailed(e)
}
})
}
type eventRequestPausedOptions struct {
allowList, denyList []*regexp2.Regexp
denyPrivateIPs bool

View File

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

View File

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

View File

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

View File

@@ -446,7 +446,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
mode := pdfengines.FormDataPdfSplitMode(form, false)
pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
encrypt := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
@@ -454,6 +454,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
var url string
err := form.
@@ -477,7 +478,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate stamp: %w", err)
}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
if err != nil {
return fmt.Errorf("convert URL to PDF: %w", err)
}
@@ -534,7 +535,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
mode := pdfengines.FormDataPdfSplitMode(form, false)
pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
encrypt := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
@@ -542,6 +543,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
var inputPath string
err := form.
@@ -562,7 +564,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
url := fmt.Sprintf("file://%s", inputPath)
options.AllowedFilePrefixes = []string{ctx.DirPath()}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
if err != nil {
return fmt.Errorf("convert HTML to PDF: %w", err)
}
@@ -616,7 +618,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
mode := pdfengines.FormDataPdfSplitMode(form, false)
pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
encrypt := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
@@ -624,6 +626,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
var (
inputPath string
@@ -653,7 +656,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
}
options.AllowedFilePrefixes = []string{ctx.DirPath()}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
if err != nil {
return fmt.Errorf("convert markdown to PDF: %w", err)
}
@@ -778,7 +781,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
return fmt.Sprintf("file://%s", inputPath), nil
}
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, embedPaths []string, embedsMetadata map[string]map[string]string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error {
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, encrypt gotenberg.EncryptOptions, embedPaths []string, embedsMetadata map[string]map[string]string, facturX gotenberg.FacturX, facturxXmlPath string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error {
outputPath := ctx.GeneratePath(".pdf")
// See https://github.com/gotenberg/gotenberg/issues/1130.
filename := ctx.OutputFilename(outputPath)
@@ -840,7 +843,17 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
return fmt.Errorf("convert to PDF: %w", err)
}
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
if err != nil {
return err
}
err = pdfengines.ValidatePdfEncryptCompat(encrypt)
if err != nil {
return err
}
err = pdfengines.ValidateFacturXCompat(facturX, facturxXmlPath, pdfFormats)
if err != nil {
return err
}
@@ -865,6 +878,8 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
return fmt.Errorf("rotate PDFs: %w", err)
}
pdfFormats = pdfengines.FacturXPdfFormats(ctx, engine, facturX, pdfFormats, true, nil)
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil {
return fmt.Errorf("convert PDF(s): %w", err)
@@ -887,7 +902,12 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
return fmt.Errorf("set embeds metadata: %w", err)
}
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
err = pdfengines.ApplyFacturXStub(ctx, engine, facturX, facturxXmlPath, convertOutputPaths)
if err != nil {
return fmt.Errorf("apply Factur-X: %w", err)
}
err = pdfengines.EncryptPdfStub(ctx, engine, encrypt, convertOutputPaths)
if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err)
}

View File

@@ -14,104 +14,148 @@ import (
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func printToPdfActionFunc(logger *slog.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc {
func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc {
return func(ctx context.Context) error {
paperHeight := options.PaperHeight
pageRanges := options.PageRanges
// ctx is the chromedp task context, derived from context.Background(),
// so the span is started under reqCtx to keep print_to_pdf in the
// conversion trace instead of orphaning it into a new one.
_, span := gotenberg.Tracer().Start(reqCtx, "chromium.print_to_pdf",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(printToPdfAttrs(options)...),
)
defer span.End()
if options.SinglePage {
logger.DebugContext(ctx, "single page PDF")
err := func() error {
paperHeight := options.PaperHeight
pageRanges := options.PageRanges
_, _, _, _, _, cssContentSize, err := page.GetLayoutMetrics().Do(ctx)
if err != nil {
return fmt.Errorf("get layout metrics: %w", err)
if options.SinglePage {
logger.DebugContext(ctx, "single page PDF")
_, _, _, _, _, cssContentSize, err := page.GetLayoutMetrics().Do(ctx)
if err != nil {
return fmt.Errorf("get layout metrics: %w", err)
}
// There are 96 CSS pixels per inch.
// See https://issues.chromium.org/issues/40267771#comment14.
// We add top and bottom margins so that the content area
// is large enough to fit the entire content.
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
pageRanges = "1" // little dirty hack to avoid leftovers.
}
// There are 96 CSS pixels per inch.
// See https://issues.chromium.org/issues/40267771#comment14.
// We add top and bottom margins so that the content area
// is large enough to fit the entire content.
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
pageRanges = "1" // little dirty hack to avoid leftovers.
}
printToPdf := page.PrintToPDF().
WithTransferMode(page.PrintToPDFTransferModeReturnAsStream).
WithLandscape(options.Landscape).
WithPrintBackground(options.PrintBackground).
WithScale(options.Scale).
WithPaperWidth(options.PaperWidth).
WithPaperHeight(paperHeight).
WithMarginTop(options.MarginTop).
WithMarginBottom(options.MarginBottom).
WithMarginLeft(options.MarginLeft).
WithMarginRight(options.MarginRight).
WithPageRanges(pageRanges).
WithPreferCSSPageSize(options.PreferCssPageSize).
WithGenerateDocumentOutline(options.GenerateDocumentOutline).
// See https://github.com/gotenberg/gotenberg/issues/1210.
WithGenerateTaggedPDF(options.GenerateTaggedPdf)
printToPdf := page.PrintToPDF().
WithTransferMode(page.PrintToPDFTransferModeReturnAsStream).
WithLandscape(options.Landscape).
WithPrintBackground(options.PrintBackground).
WithScale(options.Scale).
WithPaperWidth(options.PaperWidth).
WithPaperHeight(paperHeight).
WithMarginTop(options.MarginTop).
WithMarginBottom(options.MarginBottom).
WithMarginLeft(options.MarginLeft).
WithMarginRight(options.MarginRight).
WithPageRanges(pageRanges).
WithPreferCSSPageSize(options.PreferCssPageSize).
WithGenerateDocumentOutline(options.GenerateDocumentOutline).
// See https://github.com/gotenberg/gotenberg/issues/1210.
WithGenerateTaggedPDF(options.GenerateTaggedPdf)
hasCustomHeaderFooter := options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate ||
options.FooterTemplate != DefaultPdfOptions().FooterTemplate
hasCustomHeaderFooter := options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate ||
options.FooterTemplate != DefaultPdfOptions().FooterTemplate
if !hasCustomHeaderFooter {
logger.DebugContext(ctx, "no custom header nor footer")
if !hasCustomHeaderFooter {
logger.DebugContext(ctx, "no custom header nor footer")
printToPdf = printToPdf.WithDisplayHeaderFooter(false)
} else {
logger.DebugContext(ctx, "with custom header and/or footer")
printToPdf = printToPdf.WithDisplayHeaderFooter(false)
printToPdf = printToPdf.
WithDisplayHeaderFooter(true).
WithHeaderTemplate(options.HeaderTemplate).
WithFooterTemplate(options.FooterTemplate)
}
logger.DebugContext(ctx, fmt.Sprintf("print to PDF with: %+v", printToPdf))
_, stream, err := printToPdf.Do(ctx)
if err != nil {
return fmt.Errorf("print to PDF: %w", err)
}
reader := &streamReader{
ctx: ctx,
handle: stream,
r: nil,
pos: 0,
eof: false,
}
defer func() {
err = reader.Close()
if err != nil {
logger.ErrorContext(ctx, fmt.Sprintf("close reader: %s", err))
}
}()
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("open output path: %w", err)
}
defer func() {
err = file.Close()
if err != nil {
logger.ErrorContext(ctx, fmt.Sprintf("close output path: %s", err))
}
}()
buffer := bufio.NewReader(reader)
_, err = buffer.WriteTo(file)
if err != nil {
return fmt.Errorf("write result to output path: %w", err)
}
return nil
}()
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
} else {
logger.DebugContext(ctx, "with custom header and/or footer")
printToPdf = printToPdf.
WithDisplayHeaderFooter(true).
WithHeaderTemplate(options.HeaderTemplate).
WithFooterTemplate(options.FooterTemplate)
span.SetStatus(codes.Ok, "")
}
logger.DebugContext(ctx, fmt.Sprintf("print to PDF with: %+v", printToPdf))
return err
}
}
_, stream, err := printToPdf.Do(ctx)
if err != nil {
return fmt.Errorf("print to PDF: %w", err)
}
reader := &streamReader{
ctx: ctx,
handle: stream,
r: nil,
pos: 0,
eof: false,
}
defer func() {
err = reader.Close()
if err != nil {
logger.ErrorContext(ctx, fmt.Sprintf("close reader: %s", err))
}
}()
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("open output path: %w", err)
}
defer func() {
err = file.Close()
if err != nil {
logger.ErrorContext(ctx, fmt.Sprintf("close output path: %s", err))
}
}()
buffer := bufio.NewReader(reader)
_, err = buffer.WriteTo(file)
if err != nil {
return fmt.Errorf("write result to output path: %w", err)
}
return nil
// printToPdfAttrs derives bounded, low-cardinality attributes from the print
// options. Raw header/footer templates and page ranges are reduced to booleans
// to avoid leaking document content and exploding cardinality.
func printToPdfAttrs(options PdfOptions) []attribute.KeyValue {
return []attribute.KeyValue{
attribute.Bool("gotenberg.chromium.print.landscape", options.Landscape),
attribute.Bool("gotenberg.chromium.print.print_background", options.PrintBackground),
attribute.Float64("gotenberg.chromium.print.scale", options.Scale),
attribute.Float64("gotenberg.chromium.print.paper_width", options.PaperWidth),
attribute.Float64("gotenberg.chromium.print.paper_height", options.PaperHeight),
attribute.Bool("gotenberg.chromium.print.single_page", options.SinglePage),
attribute.Bool("gotenberg.chromium.print.prefer_css_page_size", options.PreferCssPageSize),
attribute.Bool("gotenberg.chromium.print.generate_tagged_pdf", options.GenerateTaggedPdf),
attribute.Bool("gotenberg.chromium.print.has_page_ranges", options.PageRanges != ""),
attribute.Bool("gotenberg.chromium.print.has_header", options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate),
attribute.Bool("gotenberg.chromium.print.has_footer", options.FooterTemplate != DefaultPdfOptions().FooterTemplate),
}
}

View File

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

View File

@@ -10,8 +10,10 @@ import (
"os/exec"
"regexp"
"strings"
"sync"
"syscall"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
"go.opentelemetry.io/otel/trace"
@@ -159,6 +161,9 @@ func buildExifToolWriteArgs(metadata map[string]any) ([]string, error) {
// [gotenberg.PdfEngine] interface.
type ExifTool struct {
binPath string
version string
versionOnce sync.Once
}
// Descriptor returns [ExifTool]'s module descriptor.
@@ -193,26 +198,53 @@ func (engine *ExifTool) Validate() error {
// Debug returns additional debug data.
func (engine *ExifTool) Debug() map[string]any {
debug := make(map[string]any)
return map[string]any{"version": engine.detectVersion()}
}
cmd := exec.Command(engine.binPath, "-ver") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
// detectVersion resolves the ExifTool version once, preferring the value
// captured at image build time so it never spawns ExifTool at runtime. It falls
// back to running exiftool -ver for local or non-Docker builds.
func (engine *ExifTool) detectVersion() string {
engine.versionOnce.Do(func() {
if v, ok := gotenberg.BuildVersion("exiftool"); ok {
engine.version = v
return
}
output, err := cmd.Output()
if err != nil {
debug["version"] = err.Error()
return debug
cmd := exec.Command(engine.binPath, "-ver") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
output, err := cmd.Output()
if err != nil {
engine.version = err.Error()
return
}
engine.version = strings.TrimSpace(string(output))
})
return engine.version
}
// spanAttrs returns the client-span attributes for an ExifTool invocation: the
// server address and the ExifTool version, plus any extra attributes. The
// version rides on every span so a trace records which ExifTool ran the
// operation.
func (engine *ExifTool) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue {
attrs := make([]attribute.KeyValue, 0, 2+len(extra))
attrs = append(attrs, semconv.ServerAddress(engine.binPath))
if v := engine.detectVersion(); v != "" {
attrs = append(attrs, attribute.String("gotenberg.exiftool.version", v))
}
debug["version"] = strings.TrimSpace(string(output))
return debug
return append(attrs, extra...)
}
// Merge is not available in this implementation.
func (engine *ExifTool) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Merge",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -226,7 +258,7 @@ func (engine *ExifTool) Merge(ctx context.Context, logger *slog.Logger, inputPat
func (engine *ExifTool) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Split",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -240,7 +272,7 @@ func (engine *ExifTool) Split(ctx context.Context, logger *slog.Logger, mode got
func (engine *ExifTool) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Flatten",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -254,7 +286,7 @@ func (engine *ExifTool) Flatten(ctx context.Context, logger *slog.Logger, inputP
func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Convert",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -269,7 +301,7 @@ func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, format
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.ReadMetadata",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -326,7 +358,7 @@ func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *slog.Logger, i
func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.WriteMetadata",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -371,7 +403,7 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *slog.Logger,
func (engine *ExifTool) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.PageCount",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -424,7 +456,7 @@ func (engine *ExifTool) PageCount(ctx context.Context, logger *slog.Logger, inpu
func (engine *ExifTool) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.WriteBookmarks",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -438,7 +470,7 @@ func (engine *ExifTool) WriteBookmarks(ctx context.Context, logger *slog.Logger,
func (engine *ExifTool) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.ReadBookmarks",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -449,10 +481,10 @@ func (engine *ExifTool) ReadBookmarks(ctx context.Context, logger *slog.Logger,
}
// Encrypt is not available in this implementation.
func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Encrypt",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -466,7 +498,7 @@ func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputP
func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.EmbedFiles",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -480,7 +512,7 @@ func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *slog.Logger, fil
func (engine *ExifTool) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Watermark",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -494,7 +526,7 @@ func (engine *ExifTool) Watermark(ctx context.Context, logger *slog.Logger, inpu
func (engine *ExifTool) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Stamp",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -508,7 +540,7 @@ func (engine *ExifTool) Stamp(ctx context.Context, logger *slog.Logger, inputPat
func (engine *ExifTool) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Rotate",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -523,6 +555,16 @@ func (engine *ExifTool) EmbedFilesMetadata(ctx context.Context, logger *slog.Log
return fmt.Errorf("set embeds metadata with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// InjectFacturXXMP is not available in this implementation.
func (engine *ExifTool) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error {
return fmt.Errorf("inject Factur-X XMP with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// ReadPdfAConformance is not available in this implementation.
func (engine *ExifTool) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
return "", "", fmt.Errorf("read PDF/A conformance with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Interface guards.
var (
_ gotenberg.Module = (*ExifTool)(nil)

View File

@@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"strings"
"sync"
"syscall"
"time"
@@ -36,7 +37,7 @@ var (
ErrUnoException = errors.New("uno exception")
// ErrRuntimeException happens when unoconverter returns exit code 6.
ErrRuntimeException = errors.New("uno exception")
ErrRuntimeException = errors.New("runtime exception")
// ErrCoreDumped happens randomly; sometimes a conversion will work as
// expected, and some other time the same conversion will fail.
@@ -53,11 +54,15 @@ type Api struct {
libreOffice libreOffice
supervisor gotenberg.ProcessSupervisor
version string
versionOnce sync.Once
reqsCounter metric.Int64Counter
errsCounter metric.Int64Counter
conversionDurationCounter metric.Float64Histogram
queueWaitDurationCounter metric.Float64Histogram
pdfOutputSizeCounter metric.Int64Histogram
coreDumpedRetriesCounter metric.Int64Counter
}
// Options gathers available options when converting a document to PDF.
@@ -370,7 +375,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
// Process.
a.libreOffice = newLibreOfficeProcess(a.args)
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"), 1, flags.MustDuration("libreoffice-idle-shutdown-timeout"))
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, "libreoffice", a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"), 1, flags.MustDuration("libreoffice-idle-shutdown-timeout"))
// Metrics.
meter := gotenberg.Meter()
@@ -468,6 +473,15 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
return fmt.Errorf("create libreoffice.pdf.output.size histogram: %w", err)
}
a.coreDumpedRetriesCounter, err = meter.Int64Counter(
"libreoffice.conversion.retries.total",
metric.WithDescription("Total number of LibreOffice conversion retries after a core dump"),
metric.WithUnit("{retry}"),
)
if err != nil {
return fmt.Errorf("create libreoffice.conversion.retries.total counter: %w", err)
}
return nil
}
@@ -477,12 +491,12 @@ func (a *Api) Validate() error {
_, statErr := os.Stat(a.args.binPath)
if os.IsNotExist(statErr) {
err = errors.Join(err, fmt.Errorf("LibreOffice binary path does not exist: %w", statErr))
err = errors.Join(err, fmt.Errorf("LibreOffice binary does not exist at %q; check the LIBREOFFICE_BIN_PATH environment variable: %w", a.args.binPath, statErr))
}
_, statErr = os.Stat(a.args.unoBinPath)
if os.IsNotExist(statErr) {
err = errors.Join(err, fmt.Errorf("unoconverter binary path does not exist: %w", statErr))
err = errors.Join(err, fmt.Errorf("unoconverter binary does not exist at %q; check the UNOCONVERTER_BIN_PATH environment variable: %w", a.args.unoBinPath, statErr))
}
return err
@@ -530,19 +544,46 @@ func (a *Api) Stop(ctx context.Context) error {
// Debug returns additional debug data.
func (a *Api) Debug() map[string]any {
debug := make(map[string]any)
return map[string]any{"version": a.detectVersion()}
}
cmd := exec.Command(a.args.binPath, "--version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
// detectVersion resolves the LibreOffice version once, preferring the value
// captured at image build time so it never spawns LibreOffice at runtime. It
// falls back to running soffice --version for local or non-Docker builds.
func (a *Api) detectVersion() string {
a.versionOnce.Do(func() {
if v, ok := gotenberg.BuildVersion("libreoffice-api"); ok {
a.version = v
return
}
output, err := cmd.Output()
if err != nil {
debug["version"] = err.Error()
return debug
cmd := exec.Command(a.args.binPath, "--version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
output, err := cmd.Output()
if err != nil {
a.version = err.Error()
return
}
a.version = strings.TrimSpace(string(output))
})
return a.version
}
// spanAttrs returns the client-span attributes for a LibreOffice invocation:
// the server address and the LibreOffice version, plus any extra attributes.
// The version rides on every conversion span so a trace records which
// LibreOffice rendered the document.
func (a *Api) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue {
attrs := make([]attribute.KeyValue, 0, 2+len(extra))
attrs = append(attrs, semconv.ServerAddress(a.args.binPath))
if v := a.detectVersion(); v != "" {
attrs = append(attrs, attribute.String("gotenberg.libreoffice.version", v))
}
debug["version"] = strings.TrimSpace(string(output))
return debug
return append(attrs, extra...)
}
// Metrics returns the metrics.
@@ -618,76 +659,123 @@ func (a *Api) LibreOffice() (Uno, error) {
func (a *Api) Pdf(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error {
ctx, span := gotenberg.Tracer().Start(ctx, "libreoffice.Pdf",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(a.args.binPath)),
trace.WithAttributes(a.spanAttrs()...),
)
defer span.End()
start := time.Now()
var conversionStart time.Time
span.SetAttributes(
attribute.Int64("gotenberg.queue.depth_at_arrival", a.supervisor.ReqQueueSize()),
attribute.Int64("gotenberg.conversions_since_last_restart", a.supervisor.ConversionsSinceRestart()),
)
span.SetAttributes(conversionRequestAttributes(inputPath, options)...)
err := a.supervisor.Run(ctx, logger, func() error {
conversionStart = time.Now()
return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options)
})
// ErrCoreDumped happens randomly (https://github.com/gotenberg/gotenberg/issues/639);
// retry the conversion, but cap the retries so a permanently failing
// document cannot loop forever. Each attempt records its own metrics.
const maxCoreDumpedRetries = 10
// Determine status and error reason.
status := "success"
reason := ""
var err error
var reason string
for attempt := 0; ; attempt++ {
start := time.Now()
var conversionStart time.Time
if err != nil {
switch {
case errors.Is(err, context.DeadlineExceeded):
status = "timeout"
reason = "timeout"
case errors.Is(err, context.Canceled):
err = a.supervisor.Run(ctx, logger, func() error {
conversionStart = time.Now()
return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options)
})
// Determine status and error reason.
status := "success"
reason = ""
if err != nil {
status = "error"
reason = "context_cancelled"
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) || errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
status = "error"
reason = "libreoffice_unavailable"
default:
status = "error"
reason = "unknown"
}
}
// Record metrics.
attrs := metric.WithAttributes(attribute.String("status", status))
a.reqsCounter.Add(ctx, 1, attrs)
if reason != "" {
a.errsCounter.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
}
if !conversionStart.IsZero() {
queueWait := conversionStart.Sub(start).Seconds()
a.queueWaitDurationCounter.Record(ctx, queueWait, attrs)
conversionDuration := time.Since(conversionStart).Seconds()
a.conversionDurationCounter.Record(ctx, conversionDuration, attrs)
}
if err == nil {
stat, statErr := os.Stat(outputPath)
if statErr == nil {
a.pdfOutputSizeCounter.Record(ctx, stat.Size(), attrs)
if errors.Is(err, context.DeadlineExceeded) {
status = "timeout"
}
reason = libreofficeErrorType(err)
}
span.SetStatus(codes.Ok, "")
return nil
}
// See https://github.com/gotenberg/gotenberg/issues/639.
if errors.Is(err, ErrCoreDumped) {
logger.DebugContext(ctx, fmt.Sprintf("got a '%s' error, retry conversion", err))
return a.Pdf(ctx, logger, inputPath, outputPath, options)
// Record metrics for this attempt.
attrs := metric.WithAttributes(attribute.String("status", status))
a.reqsCounter.Add(ctx, 1, attrs)
if reason != "" {
a.errsCounter.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
}
if !conversionStart.IsZero() {
queueWait := conversionStart.Sub(start).Seconds()
a.queueWaitDurationCounter.Record(ctx, queueWait, attrs)
conversionDuration := time.Since(conversionStart).Seconds()
a.conversionDurationCounter.Record(ctx, conversionDuration, attrs)
}
if err == nil {
stat, statErr := os.Stat(outputPath)
if statErr == nil {
a.pdfOutputSizeCounter.Record(ctx, stat.Size(), attrs)
span.SetAttributes(attribute.Int64("gotenberg.conversion.output.bytes", stat.Size()))
}
span.SetStatus(codes.Ok, "")
return nil
}
if errors.Is(err, ErrCoreDumped) && attempt < maxCoreDumpedRetries {
logger.DebugContext(ctx, fmt.Sprintf("got a '%s' error, retry conversion (attempt %d)", err, attempt+1))
span.AddEvent("conversion.retry", trace.WithAttributes(
attribute.Int("attempt", attempt+1),
))
a.coreDumpedRetriesCounter.Add(ctx, 1)
continue
}
break
}
gotenberg.SpanErrorType(span, reason)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return fmt.Errorf("supervisor run task: %w", err)
}
// conversionRequestAttributes derives low-cardinality attributes describing the
// requested conversion: the input document size and the requested PDF format
// options.
func conversionRequestAttributes(inputPath string, options Options) []attribute.KeyValue {
attrs := []attribute.KeyValue{
attribute.String("gotenberg.libreoffice.pdf_a", options.PdfFormats.PdfA),
attribute.Bool("gotenberg.libreoffice.pdf_ua", options.PdfFormats.PdfUa),
attribute.Bool("gotenberg.conversion.landscape", options.Landscape),
attribute.Bool("gotenberg.conversion.has_page_ranges", options.PageRanges != ""),
}
if info, err := os.Stat(inputPath); err == nil {
attrs = append(attrs, attribute.Int64("gotenberg.conversion.input.bytes", info.Size()))
}
return attrs
}
// libreofficeErrorType maps a conversion error to LibreOffice's bounded reason
// value, reused as the span error.type. Generic failures fall back to
// [gotenberg.ClassifyError].
func libreofficeErrorType(err error) string {
switch {
case errors.Is(err, ErrInvalidPdfFormats):
return gotenberg.ErrorTypeInvalidInput
case errors.Is(err, ErrUnoException), errors.Is(err, ErrRuntimeException):
return "libreoffice_exception"
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded), errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
return "libreoffice_unavailable"
default:
return gotenberg.ClassifyError(err)
}
}
// Extensions returns the file extensions available for conversions.
// FIXME: don't care, take all on the route level?
func (a *Api) Extensions() []string {

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import (
"log/slog"
"net"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
@@ -69,11 +70,13 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
// LibreOffice fetches external content (OOXML images via
// TargetMode=External, RTF INCLUDEPICTURE, ODT linked images) inside
// its own libcurl. Route those fetches through the in-process proxy
// so the chromium/webhook SSRF filters apply.
if err := writeSofficeProxyConfig(userProfileDirPath, proxy.Addr()); err != nil {
// its own libcurl. The profile config routes those fetches through the
// in-process proxy so the chromium/webhook SSRF filters apply, and
// blocks content linked from untrusted locations so absolute-path
// (file://) and direct fetches are dropped at the source.
if err := writeSofficeProfileConfig(userProfileDirPath, proxy.Addr()); err != nil {
_ = proxy.Stop(context.Background())
return fmt.Errorf("write soffice proxy config: %w", err)
return fmt.Errorf("write soffice profile config: %w", err)
}
sofficeEnv := sofficeProxyEnv(os.Environ(), proxy.Addr())
@@ -195,7 +198,7 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
select {
case err = <-connChan:
if err != nil {
return fmt.Errorf("LibreOffice socket not available: %w", err)
return fmt.Errorf("LibreOffice did not become available within the start timeout; increase --libreoffice-start-timeout or check system resources: %w", err)
}
logger.DebugContext(context.Background(), "LibreOffice socket available")
@@ -203,7 +206,7 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
return nil
case err = <-waitChan:
return fmt.Errorf("LibreOffice process exited: %w", err)
return fmt.Errorf("LibreOffice exited unexpectedly during startup; check system resources such as memory, disk, and permissions: %w", err)
}
}
}
@@ -319,6 +322,16 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, input
args = append(args, "--disable-update-indexes")
}
// A CSV becomes a single Calc sheet named after the input file, and Calc's
// default page style prints that sheet name as a centered header. Uploads
// are stored under a UUID-based filename, so the UUID would otherwise leak
// into the rendered PDF. Suppress the header for CSV inputs; spreadsheets
// that carry their own page styles (XLSX, ODS) are left untouched.
// See https://github.com/gotenberg/gotenberg/issues/1568.
if strings.EqualFold(filepath.Ext(inputPath), ".csv") {
args = append(args, "--disable-calc-header")
}
args = append(args, "--export", fmt.Sprintf("ExportFormFields=%t", options.ExportFormFields))
args = append(args, "--export", fmt.Sprintf("AllowDuplicateFieldNames=%t", options.AllowDuplicateFieldNames))
args = append(args, "--export", fmt.Sprintf("ExportBookmarks=%t", options.ExportBookmarks))

View File

@@ -245,13 +245,26 @@ var hopByHopHeaders = []string{
"Upgrade",
}
// sofficeProxyConfigTmpl is the registrymodifications.xcu fragment that
// tells soffice's UCB layer to route every HTTP and HTTPS fetch through
// proxyHost:proxyPort. The %s placeholders accept the proxy host and
// port respectively (host first, port second, repeated for HTTP and
// HTTPS).
const sofficeProxyConfigTmpl = `<?xml version="1.0" encoding="UTF-8"?>
// sofficeProfileConfigTmpl is the registrymodifications.xcu the soffice
// daemon loads at startup. It does two things:
//
// 1. Routes every HTTP and HTTPS fetch through proxyHost:proxyPort so
// soffice's own libcurl fetches hit the in-process SSRF proxy.
// 2. Sets BlockUntrustedRefererLinks so soffice refuses to load content
// linked from a document that sits in an untrusted location.
//
// The second setting closes the local-read and direct-fetch vectors the
// proxy cannot see. A document that links an absolute path
// (file:///etc/...) or any URL is loaded from the per-request temp dir,
// which is never a trusted location, so soffice drops the linked content
// instead of resolving it. Embedded content (stored inside the document)
// is unaffected.
//
// The %s placeholders accept the proxy host and port respectively (host
// first, port second, repeated for HTTP and HTTPS).
const sofficeProfileConfigTmpl = `<?xml version="1.0" encoding="UTF-8"?>
<oor:items xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<item oor:path="/org.openoffice.Office.Common/Security/Scripting"><prop oor:name="BlockUntrustedRefererLinks" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetProxyType" oor:op="fuse"><value>1</value></prop></item>
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetHTTPProxyName" oor:op="fuse"><value>%s</value></prop></item>
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetHTTPProxyPort" oor:op="fuse"><value>%s</value></prop></item>
@@ -261,10 +274,11 @@ const sofficeProxyConfigTmpl = `<?xml version="1.0" encoding="UTF-8"?>
</oor:items>
`
// writeSofficeProxyConfig drops a registrymodifications.xcu file into
// writeSofficeProfileConfig drops a registrymodifications.xcu file into
// userProfileDirPath/user/ that points soffice's UCB layer at proxyAddr
// for both HTTP and HTTPS. proxyAddr must be a host:port pair.
func writeSofficeProxyConfig(userProfileDirPath, proxyAddr string) error {
// for both HTTP and HTTPS and blocks linked content from untrusted
// locations. proxyAddr must be a host:port pair.
func writeSofficeProfileConfig(userProfileDirPath, proxyAddr string) error {
host, port, err := net.SplitHostPort(proxyAddr)
if err != nil {
return fmt.Errorf("split proxy address %q: %w", proxyAddr, err)
@@ -276,7 +290,7 @@ func writeSofficeProxyConfig(userProfileDirPath, proxyAddr string) error {
return fmt.Errorf("create soffice user profile directory: %w", err)
}
body := fmt.Sprintf(sofficeProxyConfigTmpl, host, port, host, port)
body := fmt.Sprintf(sofficeProfileConfigTmpl, host, port, host, port)
err = os.WriteFile(userDir+"/registrymodifications.xcu", []byte(body), 0o600)
if err != nil {
return fmt.Errorf("write registrymodifications.xcu: %w", err)

View File

@@ -280,11 +280,11 @@ func TestLibreOfficeProxy_StopIsIdempotent(t *testing.T) {
}
}
func TestWriteSofficeProxyConfig(t *testing.T) {
func TestWriteSofficeProfileConfig(t *testing.T) {
dir := t.TempDir()
if err := writeSofficeProxyConfig(dir, "127.0.0.1:9876"); err != nil {
t.Fatalf("writeSofficeProxyConfig: %v", err)
if err := writeSofficeProfileConfig(dir, "127.0.0.1:9876"); err != nil {
t.Fatalf("writeSofficeProfileConfig: %v", err)
}
body, err := os.ReadFile(filepath.Join(dir, "user", "registrymodifications.xcu"))
@@ -297,6 +297,9 @@ func TestWriteSofficeProxyConfig(t *testing.T) {
`ooInetHTTPProxyName`, `<value>127.0.0.1</value>`,
`ooInetHTTPProxyPort`, `<value>9876</value>`,
`ooInetHTTPSProxyName`, `ooInetHTTPSProxyPort`,
// Blocks linked content from untrusted locations, closing the
// file:// local-read and direct-fetch vectors the proxy cannot see.
`BlockUntrustedRefererLinks`, `<value>true</value>`,
} {
if !strings.Contains(string(body), want) {
t.Errorf("xcu missing %q\nfull body:\n%s", want, body)
@@ -304,8 +307,8 @@ func TestWriteSofficeProxyConfig(t *testing.T) {
}
}
func TestWriteSofficeProxyConfig_InvalidAddr(t *testing.T) {
err := writeSofficeProxyConfig(t.TempDir(), "not-a-host-port")
func TestWriteSofficeProfileConfig_InvalidAddr(t *testing.T) {
err := writeSofficeProfileConfig(t.TempDir(), "not-a-host-port")
if err == nil {
t.Fatal("expected error for malformed proxy address")
}

View File

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

View File

@@ -30,7 +30,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
splitMode := pdfengines.FormDataPdfSplitMode(form, false)
pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
encrypt := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
@@ -38,6 +38,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
stampFile := pdfengines.FormDataPdfStampFile(form)
angle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
zeroValuedSplitMode := gotenberg.SplitMode{}
@@ -313,13 +314,27 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
return fmt.Errorf("validate stamp: %w", err)
}
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
if err != nil {
return err
}
err = pdfengines.ValidatePdfEncryptCompat(encrypt)
if err != nil {
return err
}
err = pdfengines.ValidateFacturXCompat(facturX, facturxXmlPath, pdfFormats)
if err != nil {
return err
}
// Factur-X requires PDF/A-3; default to PDF/A-3b when no format was
// requested. The conversion runs as a post-processing step below.
pdfFormats = pdfengines.FacturXPdfFormats(ctx, engine, facturX, pdfFormats, true, nil)
hasPostProcessing := watermark.Source != "" || stamp.Source != "" || angle != 0 ||
len(embedPaths) > 0 || len(metadata) > 0 || flatten
len(embedPaths) > 0 || len(metadata) > 0 || flatten || facturX.ConformanceLevel != ""
outputPaths := make([]string, len(inputPaths))
for i, inputPath := range inputPaths {
@@ -385,7 +400,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("A PDF format in '%+v' is not supported", pdfFormats),
fmt.Sprintf("The PDF format '%s' is not supported. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.", pdfFormats.PdfA),
),
)
}
@@ -503,7 +518,12 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
return fmt.Errorf("set embeds metadata: %w", err)
}
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
err = pdfengines.ApplyFacturXStub(ctx, engine, facturX, facturxXmlPath, outputPaths)
if err != nil {
return fmt.Errorf("apply Factur-X: %w", err)
}
err = pdfengines.EncryptPdfStub(ctx, engine, encrypt, outputPaths)
if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err)
}

View File

@@ -13,8 +13,10 @@ import (
"sort"
"strconv"
"strings"
"sync"
"syscall"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
"go.opentelemetry.io/otel/trace"
@@ -30,6 +32,9 @@ func init() {
// [gotenberg.PdfEngine] interface.
type PdfCpu struct {
binPath string
version string
versionOnce sync.Once
}
type pdfcpuBookmark struct {
@@ -74,35 +79,60 @@ func (engine *PdfCpu) Validate() error {
// Debug returns additional debug data.
func (engine *PdfCpu) Debug() map[string]any {
debug := make(map[string]any)
return map[string]any{"version": engine.detectVersion()}
}
cmd := exec.Command(engine.binPath, "version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
output, err := cmd.Output()
if err != nil {
debug["version"] = err.Error()
return debug
}
debug["version"] = "Unable to determine pdfcpu version"
lines := strings.SplitSeq(string(output), "\n")
for line := range lines {
if after, ok := strings.CutPrefix(line, "pdfcpu:"); ok {
debug["version"] = strings.TrimSpace(after)
break
// detectVersion resolves the pdfcpu version once, preferring the value captured
// at image build time so it never spawns pdfcpu at runtime. It falls back to
// running pdfcpu version for local or non-Docker builds.
func (engine *PdfCpu) detectVersion() string {
engine.versionOnce.Do(func() {
if v, ok := gotenberg.BuildVersion("pdfcpu"); ok {
engine.version = v
return
}
cmd := exec.Command(engine.binPath, "version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
output, err := cmd.Output()
if err != nil {
engine.version = err.Error()
return
}
engine.version = "Unable to determine pdfcpu version"
lines := strings.SplitSeq(string(output), "\n")
for line := range lines {
if after, ok := strings.CutPrefix(line, "pdfcpu:"); ok {
engine.version = strings.TrimSpace(after)
break
}
}
})
return engine.version
}
// spanAttrs returns the client-span attributes for a pdfcpu invocation: the
// server address and the pdfcpu version, plus any extra attributes. The version
// rides on every span so a trace records which pdfcpu ran the operation.
func (engine *PdfCpu) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue {
attrs := make([]attribute.KeyValue, 0, 2+len(extra))
attrs = append(attrs, semconv.ServerAddress(engine.binPath))
if v := engine.detectVersion(); v != "" {
attrs = append(attrs, attribute.String("gotenberg.pdfcpu.version", v))
}
return debug
return append(attrs, extra...)
}
// Merge combines multiple PDFs into a single PDF.
func (engine *PdfCpu) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Merge",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -134,7 +164,7 @@ func (engine *PdfCpu) Merge(ctx context.Context, logger *slog.Logger, inputPaths
func (engine *PdfCpu) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Split",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -203,7 +233,7 @@ func (engine *PdfCpu) Split(ctx context.Context, logger *slog.Logger, mode goten
func (engine *PdfCpu) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Flatten",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -217,7 +247,7 @@ func (engine *PdfCpu) Flatten(ctx context.Context, logger *slog.Logger, inputPat
func (engine *PdfCpu) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Convert",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -231,7 +261,7 @@ func (engine *PdfCpu) Convert(ctx context.Context, logger *slog.Logger, formats
func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.ReadMetadata",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -245,7 +275,7 @@ func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *slog.Logger, inp
func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.WriteMetadata",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -259,7 +289,7 @@ func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *slog.Logger, me
func (engine *PdfCpu) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.PageCount",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -273,7 +303,7 @@ func (engine *PdfCpu) PageCount(ctx context.Context, logger *slog.Logger, inputP
func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.ReadBookmarks",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -376,7 +406,7 @@ func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *slog.Logger, in
func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.WriteBookmarks",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -452,12 +482,22 @@ func (engine *PdfCpu) EmbedFilesMetadata(ctx context.Context, logger *slog.Logge
return fmt.Errorf("set embeds metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// InjectFacturXXMP is not available in this implementation.
func (engine *PdfCpu) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error {
return fmt.Errorf("inject Factur-X XMP with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// ReadPdfAConformance is not available in this implementation.
func (engine *PdfCpu) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
return "", "", fmt.Errorf("read PDF/A conformance with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
// without modifying the main PDF content.
func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.EmbedFiles",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -493,30 +533,38 @@ func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *slog.Logger, fileP
}
// Encrypt adds password protection to a PDF file using pdfcpu.
func (engine *PdfCpu) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
func (engine *PdfCpu) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Encrypt",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
if userPassword == "" {
err := errors.New("user password cannot be empty")
ownerPassword := opts.OwnerPassword
if ownerPassword == "" {
ownerPassword = opts.UserPassword
}
// An empty user password is allowed: it produces an owner-only document.
if opts.UserPassword == "" && ownerPassword == "" {
err := errors.New("at least a user or owner password is required")
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
if ownerPassword == "" {
ownerPassword = userPassword
// pdfcpu only supports coarse permissions: all actions or none.
perm := "all"
if opts.Permissions.Restricted() {
perm = "none"
}
args := make([]string, 0, 11)
args = append(args, "encrypt")
args = append(args, "--mode", "aes")
args = append(args, "--upw", userPassword)
args = append(args, "--upw", opts.UserPassword)
args = append(args, "--opw", ownerPassword)
args = append(args, "--perm", "all")
args = append(args, "--perm", perm)
args = append(args, inputPath, inputPath)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
@@ -543,7 +591,7 @@ func (engine *PdfCpu) Encrypt(ctx context.Context, logger *slog.Logger, inputPat
func (engine *PdfCpu) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Watermark",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -562,7 +610,7 @@ func (engine *PdfCpu) Watermark(ctx context.Context, logger *slog.Logger, inputP
func (engine *PdfCpu) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Stamp",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
@@ -581,7 +629,7 @@ func (engine *PdfCpu) Stamp(ctx context.Context, logger *slog.Logger, inputPath
func (engine *PdfCpu) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Rotate",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()

View File

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

View File

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

View File

@@ -42,6 +42,7 @@ type PdfEngines struct {
watermarkNames []string
stampNames []string
rotateNames []string
facturXNames []string
engines []gotenberg.PdfEngine
disableRoutes bool
}
@@ -66,6 +67,7 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
fs.StringSlice("pdfengines-watermark-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the watermark feature - empty means all")
fs.StringSlice("pdfengines-stamp-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the stamp feature - empty means all")
fs.StringSlice("pdfengines-rotate-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the rotate feature - empty means all")
fs.StringSlice("pdfengines-factur-x-engines", []string{"qpdf"}, "Set the PDF engines and their order for the Factur-X XMP feature - empty means all")
fs.Bool("pdfengines-disable-routes", false, "Disable the routes")
// Deprecated flags.
@@ -99,6 +101,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
watermarkNames := flags.MustStringSlice("pdfengines-watermark-engines")
stampNames := flags.MustStringSlice("pdfengines-stamp-engines")
rotateNames := flags.MustStringSlice("pdfengines-rotate-engines")
facturXNames := flags.MustStringSlice("pdfengines-factur-x-engines")
mod.disableRoutes = flags.MustBool("pdfengines-disable-routes")
engines, err := ctx.Modules(new(gotenberg.PdfEngine))
@@ -195,6 +198,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
mod.rotateNames = rotateNames
}
mod.facturXNames = defaultNames
if len(facturXNames) > 0 {
mod.facturXNames = facturXNames
}
return nil
}
@@ -203,7 +211,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
// actually exist.
func (mod *PdfEngines) Validate() error {
if len(mod.engines) == 0 {
return errors.New("no PDF engine")
return errors.New("no PDF engine is available; enable at least one engine module (e.g. qpdf, pdfcpu, pdftk, libreoffice-pdfengine, exiftool)")
}
availableEngines := make([]string, len(mod.engines))
@@ -250,6 +258,7 @@ func (mod *PdfEngines) Validate() error {
findNonExistingEngines(mod.watermarkNames)
findNonExistingEngines(mod.stampNames)
findNonExistingEngines(mod.rotateNames)
findNonExistingEngines(mod.facturXNames)
if len(nonExistingEngines) == 0 {
return nil
@@ -276,6 +285,7 @@ func (mod *PdfEngines) SystemMessages() []string {
fmt.Sprintf("watermark engines - %s", strings.Join(mod.watermarkNames, " ")),
fmt.Sprintf("stamp engines - %s", strings.Join(mod.stampNames, " ")),
fmt.Sprintf("rotate engines - %s", strings.Join(mod.rotateNames, " ")),
fmt.Sprintf("factur-x engines - %s", strings.Join(mod.facturXNames, " ")),
}
}
@@ -310,6 +320,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
engines(mod.watermarkNames),
engines(mod.stampNames),
engines(mod.rotateNames),
engines(mod.facturXNames),
), nil
}
@@ -340,6 +351,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
watermarkRoute(engine),
stampRoute(engine),
rotateRoute(engine),
facturXRoute(engine),
}, nil
}

View File

@@ -467,21 +467,244 @@ func EmbedFilesMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metada
return nil
}
// FormDataPdfEncrypt extracts encryption parameters from form data.
func FormDataPdfEncrypt(form *api.FormData) (userPassword, ownerPassword string) {
form.String("userPassword", &userPassword, "")
form.String("ownerPassword", &ownerPassword, "")
return userPassword, ownerPassword
// FormDataPdfFacturX extracts the Factur-X parameters and the invoice XML path
// from form data. Factur-X is requested when both facturxConformanceLevel and
// facturxXml are provided. The embedded XML always takes the canonical
// [gotenberg.FacturXDocumentFileName] name.
func FormDataPdfFacturX(form *api.FormData) (gotenberg.FacturX, string) {
var (
facturxXmlPath string
conformanceLevel string
documentType string
version string
)
form.
FacturXXml(&facturxXmlPath).
Custom("facturxConformanceLevel", func(value string) error {
conformanceLevel = value
switch value {
case "",
gotenberg.FacturXConformanceMinimum,
gotenberg.FacturXConformanceBasicWL,
gotenberg.FacturXConformanceBasic,
gotenberg.FacturXConformanceEN16931,
gotenberg.FacturXConformanceExtended,
gotenberg.FacturXConformanceXRechnung:
return nil
default:
return fmt.Errorf("unsupported conformance level '%s'", value)
}
}).
Custom("facturxDocumentType", func(value string) error {
if value == "" {
documentType = gotenberg.FacturXDocumentTypeInvoice
return nil
}
documentType = value
switch value {
case gotenberg.FacturXDocumentTypeInvoice,
gotenberg.FacturXDocumentTypeOrder,
gotenberg.FacturXDocumentTypeOrderResponse,
gotenberg.FacturXDocumentTypeOrderChange:
return nil
default:
return fmt.Errorf("unsupported document type '%s'", value)
}
}).
String("facturxVersion", &version, "1.0")
return gotenberg.FacturX{
ConformanceLevel: conformanceLevel,
DocumentType: documentType,
DocumentFileName: gotenberg.FacturXDocumentFileName,
Version: version,
}, facturxXmlPath
}
// EncryptPdfStub adds password protection to PDF files.
func EncryptPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, userPassword, ownerPassword string, inputPaths []string) error {
if userPassword == "" {
// isPdfA3 reports whether the format is a PDF/A-3 variant, the only family that
// allows the embedded files Factur-X requires.
func isPdfA3(pdfA string) bool {
return pdfA == gotenberg.PdfA3a || pdfA == gotenberg.PdfA3b || pdfA == gotenberg.PdfA3u
}
// ValidateFacturXCompat enforces the Factur-X pairing and PDF/A-3 rules. It
// returns a 400 error when the request is half-specified, or when an explicit
// PDF/A format is not a PDF/A-3 variant.
func ValidateFacturXCompat(facturX gotenberg.FacturX, facturxXmlPath string, pdfFormats gotenberg.PdfFormats) error {
if facturX.ConformanceLevel == "" && facturxXmlPath == "" {
return nil
}
if facturX.ConformanceLevel == "" {
return api.WrapError(
errors.New("facturxConformanceLevel is required when facturxXml is provided"),
api.NewSentinelHttpError(http.StatusBadRequest, "Invalid form data: 'facturxConformanceLevel' is required when 'facturxXml' is provided"),
)
}
if facturxXmlPath == "" {
return api.WrapError(
errors.New("facturxXml is required when facturxConformanceLevel is set"),
api.NewSentinelHttpError(http.StatusBadRequest, "Invalid form data: 'facturxXml' file is required when 'facturxConformanceLevel' is set"),
)
}
if pdfFormats.PdfA != "" && !isPdfA3(pdfFormats.PdfA) {
return api.WrapError(
fmt.Errorf("Factur-X requires PDF/A-3, got '%s'", pdfFormats.PdfA),
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid form data: Factur-X requires a PDF/A-3 variant (PDF/A-3a, PDF/A-3b, or PDF/A-3u), got '%s'", pdfFormats.PdfA)),
)
}
return nil
}
// FacturXPdfFormats returns the PDF/A formats to convert to so the output meets
// Factur-X's PDF/A-3 requirement. It returns pdfFormats unchanged when Factur-X
// is not requested or the caller already asked for a PDF/A-3 variant. Otherwise
// it defaults to PDF/A-3b, except for pre-existing PDFs (sourceDoc false) that
// already carry PDF/A-3, which are left untouched.
func FacturXPdfFormats(ctx *api.Context, engine gotenberg.PdfEngine, facturX gotenberg.FacturX, pdfFormats gotenberg.PdfFormats, sourceDoc bool, inputPaths []string) gotenberg.PdfFormats {
if facturX.ConformanceLevel == "" || isPdfA3(pdfFormats.PdfA) {
return pdfFormats
}
if sourceDoc {
pdfFormats.PdfA = gotenberg.PdfA3b
return pdfFormats
}
// Pre-existing PDFs: keep an already-PDF/A-3 input as-is, otherwise default
// to PDF/A-3b.
for _, inputPath := range inputPaths {
part, _, err := engine.ReadPdfAConformance(ctx, ctx.Log(), inputPath)
if err != nil {
ctx.Log().DebugContext(ctx, fmt.Sprintf("read PDF/A conformance of '%s', assuming not PDF/A-3: %s", inputPath, err))
part = ""
}
if part != "3" {
pdfFormats.PdfA = gotenberg.PdfA3b
return pdfFormats
}
}
return pdfFormats
}
// ApplyFacturXStub turns each input PDF into a Factur-X document: it embeds the
// CII invoice XML under the canonical name with AFRelationship "Alternative",
// then injects the fx XMP metadata. The inputs must already be PDF/A-3 (see
// [FacturXPdfFormats]). It is a no-op when Factur-X is not requested.
func ApplyFacturXStub(ctx *api.Context, engine gotenberg.PdfEngine, facturX gotenberg.FacturX, facturxXmlPath string, inputPaths []string) error {
if facturX.ConformanceLevel == "" {
return nil
}
err := embedFacturXXml(ctx, engine, facturxXmlPath, inputPaths)
if err != nil {
return err
}
metadata := map[string]map[string]string{
facturX.DocumentFileName: {
"mimeType": "text/xml",
"relationship": "Alternative",
},
}
err = EmbedFilesMetadataStub(ctx, engine, metadata, inputPaths)
if err != nil {
return fmt.Errorf("set Factur-X embed metadata: %w", err)
}
err = InjectFacturXXMPStub(ctx, engine, facturX, inputPaths)
if err != nil {
return err
}
return nil
}
// embedFacturXXml embeds the Factur-X invoice XML into each PDF under the
// canonical [gotenberg.FacturXDocumentFileName] name, regardless of the
// uploaded file name.
func embedFacturXXml(ctx *api.Context, engine gotenberg.PdfEngine, facturxXmlPath string, inputPaths []string) error {
embedDir, err := ctx.CreateSubDirectory(uuid.New().String())
if err != nil {
return fmt.Errorf("create Factur-X embed subdirectory: %w", err)
}
canonicalPath := fmt.Sprintf("%s/%s", embedDir, gotenberg.FacturXDocumentFileName)
err = os.Symlink(facturxXmlPath, canonicalPath)
if err != nil {
return fmt.Errorf("symlink Factur-X invoice XML: %w", err)
}
for _, inputPath := range inputPaths {
err = engine.EmbedFiles(ctx, ctx.Log(), []string{canonicalPath}, inputPath)
if err != nil {
return fmt.Errorf("embed Factur-X invoice XML into PDF '%s': %w", inputPath, err)
}
}
return nil
}
// InjectFacturXXMPStub injects Factur-X XMP metadata into PDF files. If the
// Factur-X data is not set, it does nothing.
func InjectFacturXXMPStub(ctx *api.Context, engine gotenberg.PdfEngine, facturX gotenberg.FacturX, inputPaths []string) error {
if facturX.ConformanceLevel == "" {
return nil
}
for _, inputPath := range inputPaths {
err := engine.Encrypt(ctx, ctx.Log(), inputPath, userPassword, ownerPassword)
err := engine.InjectFacturXXMP(ctx, ctx.Log(), facturX, inputPath)
if err != nil {
return fmt.Errorf("inject Factur-X XMP into PDF '%s': %w", inputPath, err)
}
}
return nil
}
// FormDataPdfEncrypt extracts the encryption parameters and permissions from
// form data. Permissions default to allowed.
func FormDataPdfEncrypt(form *api.FormData) gotenberg.EncryptOptions {
var opts gotenberg.EncryptOptions
form.
String("userPassword", &opts.UserPassword, "").
String("ownerPassword", &opts.OwnerPassword, "").
Bool("allowPrinting", &opts.Permissions.AllowPrinting, true).
Bool("allowCopying", &opts.Permissions.AllowCopying, true).
Bool("allowModifying", &opts.Permissions.AllowModifying, true).
Bool("allowAnnotating", &opts.Permissions.AllowAnnotating, true).
Bool("allowFillingForms", &opts.Permissions.AllowFillingForms, true).
Bool("allowAssembling", &opts.Permissions.AllowAssembling, true)
return opts
}
// ValidatePdfEncryptCompat returns a 400 error when permission restrictions are
// requested without a password to anchor them.
func ValidatePdfEncryptCompat(opts gotenberg.EncryptOptions) error {
if opts.Permissions.Restricted() && opts.UserPassword == "" && opts.OwnerPassword == "" {
return api.WrapError(
errors.New("permission restrictions require a password"),
api.NewSentinelHttpError(http.StatusBadRequest, "Invalid form data: permission restrictions require a 'userPassword' or 'ownerPassword'"),
)
}
return nil
}
// EncryptPdfStub adds password protection and permission restrictions to PDF
// files. It does nothing when no password is provided.
func EncryptPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, opts gotenberg.EncryptOptions, inputPaths []string) error {
if opts.UserPassword == "" && opts.OwnerPassword == "" {
return nil
}
for _, inputPath := range inputPaths {
err := engine.Encrypt(ctx, ctx.Log(), inputPath, opts)
if err != nil {
return fmt.Errorf("encrypt PDF '%s': %w", inputPath, err)
}
@@ -699,7 +922,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
pdfFormats := FormDataPdfFormats(form)
metadata := FormDataPdfMetadata(form, false)
bookmarks := FormDataPdfBookmarks(form, false)
userPassword, ownerPassword := FormDataPdfEncrypt(form)
encrypt := FormDataPdfEncrypt(form)
embedPaths := FormDataPdfEmbeds(form)
watermark := FormDataPdfWatermark(form, false)
watermarkFile := FormDataPdfWatermarkFile(form)
@@ -707,6 +930,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
stampFile := FormDataPdfStampFile(form)
angle, rotatePages := FormDataPdfRotate(form, false)
embedsMetadata := FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := FormDataPdfFacturX(form)
var inputPaths []string
var flatten bool
@@ -729,7 +953,17 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate stamp: %w", err)
}
err = ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
err = ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
if err != nil {
return err
}
err = ValidatePdfEncryptCompat(encrypt)
if err != nil {
return err
}
err = ValidateFacturXCompat(facturX, facturxXmlPath, pdfFormats)
if err != nil {
return err
}
@@ -764,6 +998,8 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
}
}
pdfFormats = FacturXPdfFormats(ctx, engine, facturX, pdfFormats, false, outputPaths)
outputPaths, err = ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil {
return fmt.Errorf("convert PDF: %w", err)
@@ -830,7 +1066,12 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("set embeds metadata: %w", err)
}
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
err = ApplyFacturXStub(ctx, engine, facturX, facturxXmlPath, outputPaths)
if err != nil {
return fmt.Errorf("apply Factur-X: %w", err)
}
err = EncryptPdfStub(ctx, engine, encrypt, outputPaths)
if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err)
}
@@ -858,7 +1099,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
mode := FormDataPdfSplitMode(form, true)
pdfFormats := FormDataPdfFormats(form)
metadata := FormDataPdfMetadata(form, false)
userPassword, ownerPassword := FormDataPdfEncrypt(form)
encrypt := FormDataPdfEncrypt(form)
embedPaths := FormDataPdfEmbeds(form)
watermark := FormDataPdfWatermark(form, false)
watermarkFile := FormDataPdfWatermarkFile(form)
@@ -866,6 +1107,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
stampFile := FormDataPdfStampFile(form)
angle, rotatePages := FormDataPdfRotate(form, false)
embedsMetadata := FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := FormDataPdfFacturX(form)
var inputPaths []string
var flatten bool
@@ -886,7 +1128,17 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate stamp: %w", err)
}
err = ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
err = ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
if err != nil {
return err
}
err = ValidatePdfEncryptCompat(encrypt)
if err != nil {
return err
}
err = ValidateFacturXCompat(facturX, facturxXmlPath, pdfFormats)
if err != nil {
return err
}
@@ -918,6 +1170,8 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
}
}
pdfFormats = FacturXPdfFormats(ctx, engine, facturX, pdfFormats, false, outputPaths)
convertOutputPaths, err := ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil {
return fmt.Errorf("convert PDFs: %w", err)
@@ -940,7 +1194,12 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("set embeds metadata: %w", err)
}
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
err = ApplyFacturXStub(ctx, engine, facturX, facturxXmlPath, convertOutputPaths)
if err != nil {
return fmt.Errorf("apply Factur-X: %w", err)
}
err = EncryptPdfStub(ctx, engine, encrypt, convertOutputPaths)
if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err)
}
@@ -1224,20 +1483,26 @@ func encryptRoute(engine gotenberg.PdfEngine) api.Route {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
encrypt := FormDataPdfEncrypt(form)
var inputPaths []string
var userPassword string
var ownerPassword string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
MandatoryString("userPassword", &userPassword).
String("ownerPassword", &ownerPassword, "").
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, inputPaths)
// At least one password is required; an empty user password with an
// owner password yields an owner-only document.
if encrypt.UserPassword == "" && encrypt.OwnerPassword == "" {
return api.WrapError(
errors.New("no password provided"),
api.NewSentinelHttpError(http.StatusBadRequest, "Invalid form data: a 'userPassword' or 'ownerPassword' is required"),
)
}
err = EncryptPdfStub(ctx, engine, encrypt, inputPaths)
if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err)
}
@@ -1265,6 +1530,7 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route {
form := ctx.FormData()
embedPaths := FormDataPdfEmbeds(form)
embedsMetadata := FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := FormDataPdfFacturX(form)
var inputPaths []string
err := form.
@@ -1273,17 +1539,36 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route {
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
err = EmbedFilesStub(ctx, engine, embedPaths, inputPaths)
err = ValidateFacturXCompat(facturX, facturxXmlPath, gotenberg.PdfFormats{})
if err != nil {
return err
}
// Factur-X requires PDF/A-3. Convert when needed; a no-op otherwise,
// so a plain embed request keeps its inputs untouched.
pdfFormats := FacturXPdfFormats(ctx, engine, facturX, gotenberg.PdfFormats{}, false, inputPaths)
outputPaths, err := ConvertStub(ctx, engine, pdfFormats, inputPaths)
if err != nil {
return fmt.Errorf("convert PDFs: %w", err)
}
err = EmbedFilesStub(ctx, engine, embedPaths, outputPaths)
if err != nil {
return fmt.Errorf("embed files into PDFs: %w", err)
}
err = EmbedFilesMetadataStub(ctx, engine, embedsMetadata, inputPaths)
err = EmbedFilesMetadataStub(ctx, engine, embedsMetadata, outputPaths)
if err != nil {
return fmt.Errorf("set embeds metadata: %w", err)
}
err = ctx.AddOutputPaths(inputPaths...)
err = ApplyFacturXStub(ctx, engine, facturX, facturxXmlPath, outputPaths)
if err != nil {
return fmt.Errorf("apply Factur-X: %w", err)
}
err = ctx.AddOutputPaths(outputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
}
@@ -1413,3 +1698,61 @@ func rotateRoute(engine gotenberg.PdfEngine) api.Route {
},
}
}
// facturXRoute returns an [api.Route] which turns existing PDFs into Factur-X
// documents: it ensures PDF/A-3, embeds the CII invoice XML, and injects the fx
// XMP metadata.
func facturXRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/factur-x",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
pdfFormats := FormDataPdfFormats(form)
facturX, facturxXmlPath := FormDataPdfFacturX(form)
var inputPaths []string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
// Factur-X is the whole point of this route, so both fields are
// mandatory here.
if facturX.ConformanceLevel == "" || facturxXmlPath == "" {
return api.WrapError(
errors.New("facturxConformanceLevel and facturxXml are required"),
api.NewSentinelHttpError(http.StatusBadRequest, "Invalid form data: 'facturxConformanceLevel' and 'facturxXml' are both required"),
)
}
err = ValidateFacturXCompat(facturX, facturxXmlPath, pdfFormats)
if err != nil {
return err
}
pdfFormats = FacturXPdfFormats(ctx, engine, facturX, pdfFormats, false, inputPaths)
outputPaths, err := ConvertStub(ctx, engine, pdfFormats, inputPaths)
if err != nil {
return fmt.Errorf("convert PDFs: %w", err)
}
err = ApplyFacturXStub(ctx, engine, facturX, facturxXmlPath, outputPaths)
if err != nil {
return fmt.Errorf("apply Factur-X: %w", err)
}
err = ctx.AddOutputPaths(outputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
}
return nil
},
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,58 @@
package qpdf
import (
"os"
"path/filepath"
"testing"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestQPdfDetectVersion(t *testing.T) {
t.Run("prefers the build-time version without executing qpdf", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "qpdf"), []byte("qpdf version 11.9.0\n"), 0o600); err != nil {
t.Fatalf("write version file: %v", err)
}
t.Setenv(gotenberg.BuildVersionsDirPathEnvVar, dir)
// A bogus binPath would error if executed, so a correct result proves
// the build-time file is used instead of running qpdf.
engine := &QPdf{binPath: "/nonexistent/qpdf"}
if got := engine.Debug()["version"]; got != "qpdf version 11.9.0" {
t.Errorf("Debug()[version] = %v, want the build-time value", got)
}
})
t.Run("falls back to executing qpdf when no build-time version", func(t *testing.T) {
t.Setenv(gotenberg.BuildVersionsDirPathEnvVar, "")
// With no build-time file and a bogus binPath, the exec fallback runs
// and records its error rather than a build-time value.
engine := &QPdf{binPath: "/nonexistent/qpdf"}
if got := engine.Debug()["version"]; got == "qpdf version 11.9.0" {
t.Errorf("Debug()[version] = %v, expected the exec fallback", got)
}
})
t.Run("exposes the version as a span attribute", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "qpdf"), []byte("qpdf version 12.2.0"), 0o600); err != nil {
t.Fatalf("write version file: %v", err)
}
t.Setenv(gotenberg.BuildVersionsDirPathEnvVar, dir)
// spanAttrs is what gets handed to trace.WithAttributes, so its output
// is exactly what a span carries.
engine := &QPdf{binPath: "/nonexistent/qpdf"}
var got string
for _, attr := range engine.spanAttrs() {
if attr.Key == "gotenberg.qpdf.version" {
got = attr.Value.AsString()
}
}
if got != "qpdf version 12.2.0" {
t.Errorf("span attribute gotenberg.qpdf.version = %q, want the build-time value", got)
}
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,25 @@ Feature: /forms/libreoffice/convert
Page 1
"""
# A CSV becomes a single Calc sheet named after the input file, and Calc's
# default page style prints that sheet name as a centered header. Uploads are
# stored under a UUID-based filename, so the UUID must not leak into the PDF.
# See https://github.com/gotenberg/gotenberg/issues/1568.
Scenario: POST /forms/libreoffice/convert (CSV Without Sheet Name Header)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/sheet.csv | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Alice
"""
Then the "foo.pdf" PDF should NOT have content matching "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" at page 1
Scenario: POST /forms/libreoffice/convert (Many Documents)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
@@ -294,7 +313,7 @@ Feature: /forms/libreoffice/convert
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
The requested split mode is not supported, or no PDF engine could process it. Valid modes: 'intervals', 'pages'.
"""
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/pages_3.docx | file |
@@ -303,7 +322,7 @@ Feature: /forms/libreoffice/convert
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
A PDF format in '{PdfA:foo PdfUa:false}' is not supported
The PDF format 'foo' is not supported. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.
"""
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
@@ -695,6 +714,75 @@ Feature: /forms/libreoffice/convert
Then the response PDF(s) should have the "embed_1.xml" file embedded
Then the response PDF(s) should have the "embed_2.xml" file embedded
# A Factur-X request supplies the invoice XML via facturxXml plus the
# facturxConformanceLevel; Gotenberg owns the PDF/A-3, the Alternative
# relationship, and the canonical factur-x.xml name. No explicit pdfa here
# exercises the automatic PDF/A-3b default for a source document.
@convert
@factur-x
Scenario: POST /forms/libreoffice/convert (Factur-X / ZUGFeRD)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| facturxXml | testdata/embed_1.xml | file |
| facturxConformanceLevel | EN 16931 | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the response PDF(s) should be valid "PDF/A-3b" with a tolerance of 0 failed rule(s)
Then the response PDF(s) should have the "factur-x.xml" file embedded with relationship "Alternative"
Then the response PDF(s) should declare Factur-X XMP with conformance level "EN 16931"
# The base PDF is already PDF/A-3b: detection keeps it as-is, no reconversion.
@factur-x
Scenario: POST /forms/pdfengines/factur-x (Standalone, already PDF/A-3)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| pdfa | PDF/A-3b | field |
| Gotenberg-Output-Filename | base | header |
Then the response status code should be 200
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/factur-x" endpoint with the following form data and header(s):
| files | teststore/base.pdf | file |
| facturxXml | testdata/embed_1.xml | file |
| facturxConformanceLevel | BASIC | field |
| facturxDocumentType | ORDER | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the response PDF(s) should be valid "PDF/A-3b" with a tolerance of 0 failed rule(s)
Then the response PDF(s) should have the "factur-x.xml" file embedded with relationship "Alternative"
Then the response PDF(s) should declare Factur-X XMP with conformance level "BASIC"
# The base PDF is not PDF/A: detection converts it to PDF/A-3b automatically.
@factur-x
Scenario: POST /forms/pdfengines/factur-x (Standalone, converts non-PDF/A input)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| Gotenberg-Output-Filename | plain | header |
Then the response status code should be 200
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/factur-x" endpoint with the following form data and header(s):
| files | teststore/plain.pdf | file |
| facturxXml | testdata/embed_1.xml | file |
| facturxConformanceLevel | EN 16931 | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response PDF(s) should be valid "PDF/A-3b" with a tolerance of 0 failed rule(s)
Then the response PDF(s) should have the "factur-x.xml" file embedded with relationship "Alternative"
Then the response PDF(s) should declare Factur-X XMP with conformance level "EN 16931"
# facturxConformanceLevel without facturxXml is a half-specified request.
@factur-x
Scenario: POST /forms/pdfengines/factur-x (Bad Request)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/factur-x" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| facturxConformanceLevel | EN 16931 | field |
Then the response status code should be 400
# FIXME: once decrypt is done, add encrypt and check after the content of the PDF.
@convert
@metadata
@@ -851,3 +939,28 @@ Feature: /forms/libreoffice/convert
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the "foo.pdf" PDF should have 1 page(s)
# An embedded image is stored inside the document, not linked, so blocking
# untrusted linked content leaves it untouched. Guards against over-blocking.
@libreoffice-linked-content
Scenario: POST /forms/libreoffice/convert (Embedded Image Survives)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/libreoffice-embedded-image.fodt | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then there should be 1 PDF(s) in the response
Then the "foo.pdf" PDF should have 1 image(s)
# An uploaded document always loads from an untrusted location, so soffice
# refuses to resolve any content it links (absolute file:// path or external
# URL). Closes the SSRF and local-file-read vector.
@libreoffice-linked-content
Scenario: POST /forms/libreoffice/convert (Linked External Resource Blocked)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/libreoffice-linked-external.fodt | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then there should be 1 PDF(s) in the response
Then the "foo.pdf" PDF should have 0 image(s)

View File

@@ -93,7 +93,7 @@ Feature: /forms/pdfengines/convert
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
The requested PDF format is not supported, or no PDF engine could apply it. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.
"""
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |

View File

@@ -113,9 +113,31 @@ Feature: /forms/pdfengines/encrypt
Then the response status code should be 400
Then the response body should match string:
"""
Invalid form data: form field 'userPassword' is required
Invalid form data: a 'userPassword' or 'ownerPassword' is required
"""
# https://github.com/gotenberg/gotenberg/discussions/1571
# Owner-only: opens without a password but restricts printing.
Scenario: POST /forms/pdfengines/encrypt (Owner-only, restrict printing)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| ownerPassword | owner-secret | field |
| allowPrinting | false | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the response PDF(s) should NOT be encrypted
Then the response PDF(s) should NOT allow "printing"
# Permission restrictions need a password to anchor them.
Scenario: POST /forms/pdfengines/encrypt (Permissions without password)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| allowPrinting | false | field |
Then the response status code should be 400
Scenario: POST /forms/pdfengines/encrypt (Routes Disabled)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_DISABLE_ROUTES | true |

View File

@@ -108,7 +108,7 @@ Feature: /forms/pdfengines/merge
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
The requested PDF format is not supported, or no PDF engine could apply it. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.
"""
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |

View File

@@ -120,7 +120,7 @@ Feature: /forms/pdfengines/metadata/{write|read}
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should contain string:
"""
At least one PDF engine cannot process the requested metadata
The requested metadata could not be written
"""
Scenario: POST /forms/pdfengines/metadata/write (Reject Group-Prefixed Dangerous Tag)

View File

@@ -283,7 +283,7 @@ Feature: /forms/pdfengines/split
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
The requested split mode is not supported, or no PDF engine could process it. Valid modes: 'intervals', 'pages'.
"""
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
| files | testdata/pages_3.pdf | file |
@@ -294,7 +294,7 @@ Feature: /forms/pdfengines/split
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
The requested PDF format is not supported, or no PDF engine could apply it. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.
"""
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
| files | testdata/pages_3.pdf | file |

View File

@@ -83,7 +83,7 @@ Feature: /forms/pdfengines/stamp
Then the response status code should be 400
Then the response body should match string:
"""
At least one PDF engine cannot process the requested stamp source type, while others may have failed due to different issues
The requested stamp source is not supported, or no PDF engine could process it. Valid sources: 'text', 'image', 'pdf'.
"""
@download-from

View File

@@ -83,7 +83,7 @@ Feature: /forms/pdfengines/watermark
Then the response status code should be 400
Then the response body should match string:
"""
At least one PDF engine cannot process the requested stamp source type, while others may have failed due to different issues
The requested stamp source is not supported, or no PDF engine could process it. Valid sources: 'text', 'image', 'pdf'.
"""
@download-from

View File

@@ -126,3 +126,15 @@ Feature: Webhook
"timestamp": "ignore"
}
"""
Scenario: Asynchronous Trace Continuity
Given I have a default Gotenberg container
Given I have a webhook server
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
| Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
| traceparent | 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01 | header |
Then the response status code should be 204
When I wait for the asynchronous request to the webhook
Then the webhook request header "traceparent" should carry trace id "0af7651916cd43dd8448eb211c80319c"

View File

@@ -602,6 +602,31 @@ func (s *scenario) theHeaderValueShouldBe(kind, name string, expected string) er
return nil
}
func (s *scenario) theWebhookRequestHeaderShouldCarryTraceID(name, expected string) error {
if s.server == nil {
return errors.New("server not initialized")
}
if s.server.req == nil {
return errors.New("no webhook request found")
}
value := s.server.req.Header.Get(name)
if value == "" {
return fmt.Errorf("expected webhook request header %q to be set, but it is empty", name)
}
// W3C traceparent format: version "-" trace-id "-" parent-id "-" trace-flags.
parts := strings.Split(value, "-")
if len(parts) != 4 {
return fmt.Errorf("expected webhook request header %q to be a valid traceparent, but got %q", name, value)
}
if parts[1] != expected {
return fmt.Errorf("expected webhook request header %q to carry trace id %q, but actual is %q", name, expected, parts[1])
}
return nil
}
func (s *scenario) theCookieValueShouldBe(kind, name, expected string) error {
var cookies []*http.Cookie
switch {
@@ -1052,14 +1077,17 @@ func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context,
return nil
}
func (s *scenario) thePdfShouldHaveTheFollowingContentAtPage(ctx context.Context, name, kind string, page int, expected *godog.DocString) error {
// pdfPageText extracts the text of a single page from a produced PDF using
// pdftotext. name is either a literal filename or a "*_" glob resolved against
// the test store.
func (s *scenario) pdfPageText(ctx context.Context, name string, page int) (string, error) {
var path string
if !strings.HasPrefix(name, "*_") {
path = fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
_, err := os.Stat(path)
if os.IsNotExist(err) {
return fmt.Errorf("PDF %q does not exist", path)
return "", fmt.Errorf("PDF %q does not exist", path)
}
} else {
substr := strings.ReplaceAll(name, "*_", "")
@@ -1074,7 +1102,7 @@ func (s *scenario) thePdfShouldHaveTheFollowingContentAtPage(ctx context.Context
return nil
})
if err != nil {
return fmt.Errorf("walk %q: %w", s.workdir, err)
return "", fmt.Errorf("walk %q: %w", s.workdir, err)
}
}
@@ -1090,7 +1118,16 @@ func (s *scenario) thePdfShouldHaveTheFollowingContentAtPage(ctx context.Context
output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
if err != nil {
return fmt.Errorf("exec %q: %w", cmd, err)
return "", fmt.Errorf("exec %q: %w", cmd, err)
}
return output, nil
}
func (s *scenario) thePdfShouldHaveTheFollowingContentAtPage(ctx context.Context, name, kind string, page int, expected *godog.DocString) error {
output, err := s.pdfPageText(ctx, name, page)
if err != nil {
return err
}
invert := kind == "should NOT"
@@ -1106,6 +1143,30 @@ func (s *scenario) thePdfShouldHaveTheFollowingContentAtPage(ctx context.Context
return nil
}
func (s *scenario) thePdfShouldHaveContentMatchingAtPage(ctx context.Context, name, kind, pattern string, page int) error {
output, err := s.pdfPageText(ctx, name, page)
if err != nil {
return err
}
re, err := regexp.Compile(pattern)
if err != nil {
return fmt.Errorf("compile pattern %q: %w", pattern, err)
}
invert := kind == "should NOT"
if !invert && !re.MatchString(output) {
return fmt.Errorf("pattern %q not found in %q", pattern, output)
}
if invert && re.MatchString(output) {
return fmt.Errorf("pattern %q found in %q", pattern, output)
}
return nil
}
func (s *scenario) thePdfsShouldBeFlatten(ctx context.Context, kind, should string) error {
dirPath := s.teststoreDir
@@ -1156,6 +1217,67 @@ func (s *scenario) thePdfsShouldBeFlatten(ctx context.Context, kind, should stri
return nil
}
// permissionFlags maps a human action to the permission key reported in a PDF's
// encryption dictionary.
var permissionFlags = map[string]string{
"printing": "print",
"copying": "copy",
"modifying": "change",
"annotating": "addNotes",
}
// thePdfsShouldAllowAction asserts whether every response PDF permits a given
// action (printing, copying, modifying, annotating). It reads the document's
// permission flags; an unencrypted document has no restrictions.
func (s *scenario) thePdfsShouldAllowAction(ctx context.Context, kind, should, action string) error {
flag, ok := permissionFlags[action]
if !ok {
return fmt.Errorf("unsupported permission action %q", action)
}
dirPath := s.teststoreDir
_, err := os.Stat(dirPath)
if os.IsNotExist(err) {
return fmt.Errorf("directory %q does not exist", dirPath)
}
var paths []string
err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error {
if pathErr != nil {
return pathErr
}
if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
paths = append(paths, path)
}
return nil
})
if err != nil {
return fmt.Errorf("walk %q: %w", dirPath, err)
}
invert := should == "should NOT"
for _, path := range paths {
output, err := execCommandInIntegrationToolsContainer(ctx, []string{"pdfinfo", filepath.Base(path)}, path)
if err != nil {
return fmt.Errorf("read permissions of %q: %w", path, err)
}
stripped := strings.ReplaceAll(strings.ReplaceAll(output, " ", ""), "\n", "")
denied := strings.Contains(stripped, flag+":no")
allowed := strings.Contains(stripped, flag+":yes")
if invert && !denied {
return fmt.Errorf("expected PDF %q to deny %q, got: %q", path, action, output)
}
if !invert && !allowed {
return fmt.Errorf("expected PDF %q to allow %q, got: %q", path, action, output)
}
}
return nil
}
func (s *scenario) thePdfsShouldBeEncrypted(ctx context.Context, kind string, should string) error {
dirPath := s.teststoreDir
@@ -1324,6 +1446,57 @@ func (s *scenario) thePdfsShouldHaveEmbeddedFileWithRelationship(ctx context.Con
return nil
}
func (s *scenario) thePdfsShouldDeclareFacturXConformanceLevel(ctx context.Context, kind, conformanceLevel string) error {
dirPath := s.teststoreDir
_, err := os.Stat(dirPath)
if os.IsNotExist(err) {
return fmt.Errorf("directory %q does not exist", dirPath)
}
var paths []string
err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error {
if pathErr != nil {
return pathErr
}
if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
paths = append(paths, path)
}
return nil
})
if err != nil {
return fmt.Errorf("walk %q: %w", dirPath, err)
}
for _, path := range paths {
cmd := []string{
"pdfinfo",
"-meta",
filepath.Base(path),
}
output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
if err != nil {
return fmt.Errorf("exec %q: %w", cmd, err)
}
if !strings.Contains(output, "urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#") {
return errors.New("missing Factur-X namespace in XMP")
}
if !strings.Contains(output, "pdfaExtension:schemas") {
return errors.New("missing PDF/A extension schema in XMP")
}
conformanceTag := fmt.Sprintf("<fx:ConformanceLevel>%s</fx:ConformanceLevel>", conformanceLevel)
if !strings.Contains(output, conformanceTag) {
return fmt.Errorf("missing fx:ConformanceLevel %q in XMP", conformanceLevel)
}
}
return nil
}
func InitializeScenario(ctx *godog.ScenarioContext) {
s := &scenario{}
ctx.Before(func(ctx context.Context, sc *godog.Scenario) (context.Context, error) {
@@ -1351,6 +1524,7 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
ctx.Then(`^all concurrent response status codes should be (\d+)$`, s.allConcurrentResponseStatusCodesShouldBe)
ctx.Then(`^all concurrent responses should have (\d+) PDF\(s\)$`, s.allConcurrentResponsesShouldHavePdfs)
ctx.Then(`^the (response|webhook request|file request|server request) header "([^"]*)" should be "([^"]*)"$`, s.theHeaderValueShouldBe)
ctx.Then(`^the webhook request header "([^"]*)" should carry trace id "([^"]*)"$`, s.theWebhookRequestHeaderShouldCarryTraceID)
ctx.Then(`^the (response|webhook request|file request|server request) cookie "([^"]*)" should be "([^"]*)"$`, s.theCookieValueShouldBe)
ctx.Then(`^the (response|webhook request) body should match string:$`, s.theBodyShouldMatchString)
ctx.Then(`^the (response|webhook request) body should contain string:$`, s.theBodyShouldContainString)
@@ -1361,11 +1535,14 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
ctx.Then(`^the (response|webhook request) PDF\(s\) should be valid "([^"]*)" with a tolerance of (\d+) failed rule\(s\)$`, s.thePdfsShouldBeValidWithAToleranceOf)
ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) be flatten$`, s.thePdfsShouldBeFlatten)
ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) be encrypted`, s.thePdfsShouldBeEncrypted)
ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) allow "([^"]*)"$`, s.thePdfsShouldAllowAction)
ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) have the "([^"]*)" file embedded$`, s.thePdfsShouldHaveEmbeddedFile)
ctx.Then(`^the (response|webhook request) PDF\(s\) should have the "([^"]*)" file embedded with relationship "([^"]*)"$`, s.thePdfsShouldHaveEmbeddedFileWithRelationship)
ctx.Then(`^the (response|webhook request) PDF\(s\) should declare Factur-X XMP with conformance level "([^"]*)"$`, s.thePdfsShouldDeclareFacturXConformanceLevel)
ctx.Then(`^the "([^"]*)" PDF should have (\d+) page\(s\)$`, s.thePdfShouldHavePages)
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) be set to landscape orientation$`, s.thePdfShouldBeSetToLandscapeOrientation)
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage)
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have content matching "([^"]*)" at page (\d+)$`, s.thePdfShouldHaveContentMatchingAtPage)
ctx.Then(`^the "([^"]*)" PDF should have (\d+) image\(s\)$`, s.thePdfShouldHaveImages)
ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) {
if s.gotenbergContainer != nil {

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" office:version="1.2" office:mimetype="application/vnd.oasis.opendocument.text">
<office:body><office:text>
<text:p>An embedded image is stored in the document and must survive.</text:p>
<text:p><draw:frame text:anchor-type="as-char" svg:width="3cm" svg:height="3cm"><draw:image><office:binary-data>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC</office:binary-data></draw:image></draw:frame></text:p>
</office:text></office:body>
</office:document>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" office:version="1.2" office:mimetype="application/vnd.oasis.opendocument.text">
<office:body><office:text>
<text:p>An image linked by absolute path outside the document folder must not load.</text:p>
<text:p><draw:frame text:anchor-type="as-char" svg:width="3cm" svg:height="3cm"><draw:image xlink:href="file:///usr/lib/libreoffice/share/gallery/backgrounds/giraffe.png" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/></draw:frame></text:p>
</office:text></office:body>
</office:document>

3
test/integration/testdata/sheet.csv vendored Normal file
View File

@@ -0,0 +1,3 @@
Name,Amount
Alice,100
Bob,200
1 Name Amount
2 Alice 100
3 Bob 200