mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 08:32:16 +01:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98fc403478 | ||
|
|
808a96f3d0 | ||
|
|
d4c20c6b39 | ||
|
|
2050b4ae6b | ||
|
|
c0ed2dee3c | ||
|
|
f8905bac8c | ||
|
|
3b1e4cbac4 | ||
|
|
287ee5be72 | ||
|
|
9ab39b6fca | ||
|
|
5558e43821 | ||
|
|
40666529f9 | ||
|
|
0f5e1b794a | ||
|
|
3ab8c5920b | ||
|
|
54853e2cbd | ||
|
|
5c6a5c64b5 | ||
|
|
60482a5cdf | ||
|
|
a6faa892a8 | ||
|
|
4ebd977d97 | ||
|
|
a82dd9f031 | ||
|
|
8668a1d710 | ||
|
|
e7c8a6a50c | ||
|
|
11ab93aef6 | ||
|
|
1498473495 | ||
|
|
c24883b8a1 | ||
|
|
f6069ef84f | ||
|
|
7465166de7 | ||
|
|
de572fe6b5 | ||
|
|
2b20399a80 | ||
|
|
7c630ecc6e | ||
|
|
bbebad1175 | ||
|
|
387728f09a | ||
|
|
65e8bf20d4 | ||
|
|
e1c28d8450 | ||
|
|
c63dd5ce1e | ||
|
|
7a439632da | ||
|
|
525102b991 | ||
|
|
8e1ab0110f | ||
|
|
a5efccd4cc | ||
|
|
0b0e817ca5 | ||
|
|
f5b26c0b2c | ||
|
|
8003109012 | ||
|
|
a0ec46a764 | ||
|
|
b32545e589 | ||
|
|
cb461bb1fe |
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}]
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
29
.bruno/PDF Engines/Factur-X/Inject Factur-X XMP.bru
Normal file
29
.bruno/PDF Engines/Factur-X/Inject Factur-X XMP.bru
Normal 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"}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
3
Makefile
3
Makefile
@@ -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
|
||||
|
||||
@@ -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
34
build/capture-version.sh
Normal 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"
|
||||
@@ -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")
|
||||
|
||||
@@ -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
2
go.mod
@@ -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
4
go.sum
@@ -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
53
pkg/gotenberg/attrs.go
Normal 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"
|
||||
}
|
||||
62
pkg/gotenberg/attrs_test.go
Normal file
62
pkg/gotenberg/attrs_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
50
pkg/gotenberg/buildversions.go
Normal file
50
pkg/gotenberg/buildversions.go
Normal 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
|
||||
}
|
||||
81
pkg/gotenberg/buildversions_test.go
Normal file
81
pkg/gotenberg/buildversions_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
136
pkg/gotenberg/cmd_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
52
pkg/gotenberg/errortype.go
Normal file
52
pkg/gotenberg/errortype.go
Normal 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))
|
||||
}
|
||||
60
pkg/gotenberg/errortype_test.go
Normal file
60
pkg/gotenberg/errortype_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
53
pkg/gotenberg/internal/log/stdhandler_test.go
Normal file
53
pkg/gotenberg/internal/log/stdhandler_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
151
pkg/gotenberg/internal/otel/otel_test.go
Normal file
151
pkg/gotenberg/internal/otel/otel_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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].
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
87
pkg/modules/chromium/attrs_test.go
Normal file
87
pkg/modules/chromium/attrs_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
37
pkg/modules/chromium/errortype_test.go
Normal file
37
pkg/modules/chromium/errortype_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
121
pkg/modules/chromium/network_aggregate.go
Normal file
121
pkg/modules/chromium/network_aggregate.go
Normal 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
|
||||
}
|
||||
100
pkg/modules/chromium/network_aggregate_test.go
Normal file
100
pkg/modules/chromium/network_aggregate_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
37
pkg/modules/chromium/version_test.go
Normal file
37
pkg/modules/chromium/version_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
44
pkg/modules/libreoffice/api/api_request_attrs_test.go
Normal file
44
pkg/modules/libreoffice/api/api_request_attrs_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
84
pkg/modules/libreoffice/api/core_dumped_test.go
Normal file
84
pkg/modules/libreoffice/api/core_dumped_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
33
pkg/modules/libreoffice/api/errortype_test.go
Normal file
33
pkg/modules/libreoffice/api/errortype_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
169
pkg/modules/pdfengines/fallback_test.go
Normal file
169
pkg/modules/pdfengines/fallback_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
76
pkg/modules/qpdf/encrypt_test.go
Normal file
76
pkg/modules/qpdf/encrypt_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
299
pkg/modules/qpdf/facturx_test.go
Normal file
299
pkg/modules/qpdf/facturx_test.go
Normal 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&b<c>.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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
58
pkg/modules/qpdf/version_test.go
Normal file
58
pkg/modules/qpdf/version_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
62
pkg/modules/webhook/async_context.go
Normal file
62
pkg/modules/webhook/async_context.go
Normal 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()
|
||||
}
|
||||
}
|
||||
84
pkg/modules/webhook/async_context_test.go
Normal file
84
pkg/modules/webhook/async_context_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
86
pkg/modules/webhook/middleware_trace_test.go
Normal file
86
pkg/modules/webhook/middleware_trace_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
7
test/integration/testdata/libreoffice-embedded-image.fodt
vendored
Normal file
7
test/integration/testdata/libreoffice-embedded-image.fodt
vendored
Normal 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>
|
||||
7
test/integration/testdata/libreoffice-linked-external.fodt
vendored
Normal file
7
test/integration/testdata/libreoffice-linked-external.fodt
vendored
Normal 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
3
test/integration/testdata/sheet.csv
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
Name,Amount
|
||||
Alice,100
|
||||
Bob,200
|
||||
|
Reference in New Issue
Block a user