mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-15 11:52:14 +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"}
|
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||||
~userPassword:
|
~userPassword:
|
||||||
~ownerPassword:
|
~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_1.xml)
|
||||||
~embeds: @file(../test/integration/testdata/embed_2.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"}}
|
~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
|
~watermarkSource: text
|
||||||
~watermarkExpression: CONFIDENTIAL
|
~watermarkExpression: CONFIDENTIAL
|
||||||
~watermarkPages:
|
~watermarkPages:
|
||||||
|
|||||||
@@ -51,9 +51,19 @@ body:multipart-form {
|
|||||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||||
~userPassword:
|
~userPassword:
|
||||||
~ownerPassword:
|
~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_1.xml)
|
||||||
~embeds: @file(../test/integration/testdata/embed_2.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"}}
|
~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
|
~watermarkSource: text
|
||||||
~watermarkExpression: CONFIDENTIAL
|
~watermarkExpression: CONFIDENTIAL
|
||||||
~watermarkPages:
|
~watermarkPages:
|
||||||
|
|||||||
@@ -50,9 +50,19 @@ body:multipart-form {
|
|||||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||||
~userPassword:
|
~userPassword:
|
||||||
~ownerPassword:
|
~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_1.xml)
|
||||||
~embeds: @file(../test/integration/testdata/embed_2.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"}}
|
~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
|
~watermarkSource: text
|
||||||
~watermarkExpression: CONFIDENTIAL
|
~watermarkExpression: CONFIDENTIAL
|
||||||
~watermarkPages:
|
~watermarkPages:
|
||||||
|
|||||||
@@ -67,9 +67,19 @@ body:multipart-form {
|
|||||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||||
~userPassword:
|
~userPassword:
|
||||||
~ownerPassword:
|
~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_1.xml)
|
||||||
~embeds: @file(../test/integration/testdata/embed_2.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"}}
|
~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
|
~watermarkSource: text
|
||||||
~watermarkExpression: CONFIDENTIAL
|
~watermarkExpression: CONFIDENTIAL
|
||||||
~watermarkPages:
|
~watermarkPages:
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ body:multipart-form {
|
|||||||
embeds: @file(../test/integration/testdata/embed_1.xml)
|
embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||||
embeds: @file(../test/integration/testdata/embed_2.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"}}
|
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}]
|
~downloadFrom: [{"url":"https://example.com/attachment.xml","embedded":true}]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ body:multipart-form {
|
|||||||
files: @file(../../test/integration/testdata/page_1.pdf)
|
files: @file(../../test/integration/testdata/page_1.pdf)
|
||||||
userPassword: secret123
|
userPassword: secret123
|
||||||
~ownerPassword: owner456
|
~ownerPassword: owner456
|
||||||
|
~allowPrinting: false
|
||||||
|
~allowCopying: false
|
||||||
|
~allowModifying: false
|
||||||
|
~allowAnnotating: false
|
||||||
|
~allowFillingForms: false
|
||||||
|
~allowAssembling: false
|
||||||
}
|
}
|
||||||
|
|
||||||
headers {
|
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}]
|
~bookmarks: [{"title":"Page 1","page":1},{"title":"Page 2","page":2}]
|
||||||
~userPassword:
|
~userPassword:
|
||||||
~ownerPassword:
|
~ownerPassword:
|
||||||
|
~allowPrinting: false
|
||||||
|
~allowCopying: false
|
||||||
|
~allowModifying: false
|
||||||
|
~allowAnnotating: false
|
||||||
|
~allowFillingForms: false
|
||||||
|
~allowAssembling: false
|
||||||
~watermarkSource: text
|
~watermarkSource: text
|
||||||
~watermarkExpression: CONFIDENTIAL
|
~watermarkExpression: CONFIDENTIAL
|
||||||
~watermarkPages:
|
~watermarkPages:
|
||||||
@@ -31,6 +37,10 @@ body:multipart-form {
|
|||||||
~stampOptions: {"scale":"0.5 abs","rot":"45"}
|
~stampOptions: {"scale":"0.5 abs","rot":"45"}
|
||||||
~rotateAngle: 90
|
~rotateAngle: 90
|
||||||
~rotatePages:
|
~rotatePages:
|
||||||
|
~facturxXml: @file(../../test/integration/testdata/embed_1.xml)
|
||||||
|
~facturxConformanceLevel: EN 16931
|
||||||
|
~facturxDocumentType: INVOICE
|
||||||
|
~facturxVersion: 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
headers {
|
headers {
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ body:multipart-form {
|
|||||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||||
~userPassword:
|
~userPassword:
|
||||||
~ownerPassword:
|
~ownerPassword:
|
||||||
|
~allowPrinting: false
|
||||||
|
~allowCopying: false
|
||||||
|
~allowModifying: false
|
||||||
|
~allowAnnotating: false
|
||||||
|
~allowFillingForms: false
|
||||||
|
~allowAssembling: false
|
||||||
~watermarkSource: text
|
~watermarkSource: text
|
||||||
~watermarkExpression: CONFIDENTIAL
|
~watermarkExpression: CONFIDENTIAL
|
||||||
~watermarkPages:
|
~watermarkPages:
|
||||||
@@ -31,6 +37,10 @@ body:multipart-form {
|
|||||||
~stampOptions: {"scale":"0.5 abs","rot":"45"}
|
~stampOptions: {"scale":"0.5 abs","rot":"45"}
|
||||||
~rotateAngle: 90
|
~rotateAngle: 90
|
||||||
~rotatePages:
|
~rotatePages:
|
||||||
|
~facturxXml: @file(../../test/integration/testdata/embed_1.xml)
|
||||||
|
~facturxConformanceLevel: EN 16931
|
||||||
|
~facturxDocumentType: INVOICE
|
||||||
|
~facturxVersion: 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
headers {
|
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.
|
- No panics in production code paths.
|
||||||
- Validate input defensively.
|
- 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
|
### 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.
|
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_FIELDS_PREFIX=
|
||||||
LOG_STD_FORMAT=auto
|
LOG_STD_FORMAT=auto
|
||||||
LOG_STD_ENABLE_GCP_FIELDS=false
|
LOG_STD_ENABLE_GCP_FIELDS=false
|
||||||
|
LOG_STD_LEVEL_CASE=lower
|
||||||
PDFENGINES_DISABLE_ROUTES=false
|
PDFENGINES_DISABLE_ROUTES=false
|
||||||
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
|
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
|
||||||
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
|
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
|
||||||
@@ -80,6 +81,7 @@ PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
|
|||||||
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
|
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
|
||||||
PDFENGINES_EMBED_ENGINES=qpdf,pdfcpu
|
PDFENGINES_EMBED_ENGINES=qpdf,pdfcpu
|
||||||
PDFENGINES_EMBED_METADATA_ENGINES=qpdf
|
PDFENGINES_EMBED_METADATA_ENGINES=qpdf
|
||||||
|
PDFENGINES_FACTUR_X_ENGINES=qpdf
|
||||||
PROMETHEUS_NAMESPACE=gotenberg
|
PROMETHEUS_NAMESPACE=gotenberg
|
||||||
PROMETHEUS_COLLECT_INTERVAL=1s
|
PROMETHEUS_COLLECT_INTERVAL=1s
|
||||||
PROMETHEUS_DISABLE_ROUTE_TELEMETRY=true
|
PROMETHEUS_DISABLE_ROUTE_TELEMETRY=true
|
||||||
@@ -156,6 +158,7 @@ NO_CONCURRENCY=false
|
|||||||
# stamp
|
# stamp
|
||||||
# pdfengines-rotate
|
# pdfengines-rotate
|
||||||
# rotate
|
# rotate
|
||||||
|
# factur-x
|
||||||
# pdfengines-bookmarks
|
# pdfengines-bookmarks
|
||||||
# bookmarks
|
# bookmarks
|
||||||
# prometheus-metrics
|
# prometheus-metrics
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ RUN apt-get update -qq \
|
|||||||
|
|
||||||
WORKDIR /downloads
|
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
|
&& 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" \
|
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 \
|
RUN apt-get update -qq \
|
||||||
&& apt-get upgrade -yqq \
|
&& apt-get upgrade -yqq \
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
|
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
tini \
|
tini \
|
||||||
# Many users rely on curl for Docker health checks.
|
# Many users rely on curl for Docker health checks.
|
||||||
curl \
|
curl \
|
||||||
@@ -189,6 +190,19 @@ ENV QPDF_BIN_PATH=/usr/bin/qpdf
|
|||||||
ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool
|
ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool
|
||||||
ENV PDFCPU_BIN_PATH=/usr/bin/pdfcpu
|
ENV PDFCPU_BIN_PATH=/usr/bin/pdfcpu
|
||||||
|
|
||||||
|
# Capture backing-binary versions at build time so the running process reports
|
||||||
|
# them on traces without spawning the binaries at startup or per request.
|
||||||
|
# See pkg/gotenberg/buildversions.go. Chromium and LibreOffice are captured in
|
||||||
|
# the variant stages below, where they are installed.
|
||||||
|
ENV GOTENBERG_VERSIONS_DIR_PATH=/opt/gotenberg/versions
|
||||||
|
|
||||||
|
COPY --link build/capture-version.sh /opt/gotenberg/capture-version.sh
|
||||||
|
|
||||||
|
RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" qpdf "$QPDF_BIN_PATH" --version \
|
||||||
|
&& bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" exiftool "$EXIFTOOL_BIN_PATH" -ver \
|
||||||
|
&& bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" pdftk "$PDFTK_BIN_PATH" --version \
|
||||||
|
&& bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" pdfcpu "$PDFCPU_BIN_PATH" version
|
||||||
|
|
||||||
# OpenTelemetry defaults (noop - no telemetry overhead unless explicitly enabled).
|
# OpenTelemetry defaults (noop - no telemetry overhead unless explicitly enabled).
|
||||||
ENV OTEL_TRACES_EXPORTER=none
|
ENV OTEL_TRACES_EXPORTER=none
|
||||||
ENV OTEL_METRICS_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 LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
|
||||||
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
|
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
|
||||||
|
|
||||||
|
# Capture Chromium and LibreOffice versions now that both are installed.
|
||||||
|
RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" chromium "$CHROMIUM_BIN_PATH" --version \
|
||||||
|
&& bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" libreoffice-api "$LIBREOFFICE_BIN_PATH" --version
|
||||||
|
|
||||||
USER gotenberg
|
USER gotenberg
|
||||||
WORKDIR /home/gotenberg
|
WORKDIR /home/gotenberg
|
||||||
|
|
||||||
@@ -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.
|
# No LibreOffice in this variant; override the default to use all available engines.
|
||||||
ENV PDFENGINES_CONVERT_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
|
USER gotenberg
|
||||||
WORKDIR /home/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 LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
|
||||||
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
|
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
|
USER gotenberg
|
||||||
WORKDIR /home/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-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.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.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.
|
// Deprecated logging flags.
|
||||||
fs.String("log-format", gotenberg.AutoLoggingFormat, "Set the log format")
|
fs.String("log-format", gotenberg.AutoLoggingFormat, "Set the log format")
|
||||||
@@ -123,6 +124,7 @@ func Run() {
|
|||||||
LogFieldsPrefix: parsedFlags.MustString("log-fields-prefix"),
|
LogFieldsPrefix: parsedFlags.MustString("log-fields-prefix"),
|
||||||
LogStdFormat: parsedFlags.MustDeprecatedString("log-format", "log-std-format"),
|
LogStdFormat: parsedFlags.MustDeprecatedString("log-format", "log-std-format"),
|
||||||
LogStdEnableGcpFields: parsedFlags.MustDeprecatedBool("log-enable-gcp-fields", "log-std-enable-gcp-fields"),
|
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.
|
// LogLevel uses its own flag, not the format flag.
|
||||||
telemetryCfg.LogLevel = parsedFlags.MustString("log-level")
|
telemetryCfg.LogLevel = parsedFlags.MustString("log-level")
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ services:
|
|||||||
- "--log-fields-prefix=${LOG_FIELDS_PREFIX}"
|
- "--log-fields-prefix=${LOG_FIELDS_PREFIX}"
|
||||||
- "--log-std-format=${LOG_STD_FORMAT}"
|
- "--log-std-format=${LOG_STD_FORMAT}"
|
||||||
- "--log-std-enable-gcp-fields=${LOG_STD_ENABLE_GCP_FIELDS}"
|
- "--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-merge-engines=${PDFENGINES_MERGE_ENGINES}"
|
||||||
- "--pdfengines-split-engines=${PDFENGINES_SPLIT_ENGINES}"
|
- "--pdfengines-split-engines=${PDFENGINES_SPLIT_ENGINES}"
|
||||||
- "--pdfengines-flatten-engines=${PDFENGINES_FLATTEN_ENGINES}"
|
- "--pdfengines-flatten-engines=${PDFENGINES_FLATTEN_ENGINES}"
|
||||||
@@ -80,6 +81,7 @@ services:
|
|||||||
- "--pdfengines-rotate-engines=${PDFENGINES_ROTATE_ENGINES}"
|
- "--pdfengines-rotate-engines=${PDFENGINES_ROTATE_ENGINES}"
|
||||||
- "--pdfengines-embed-engines=${PDFENGINES_EMBED_ENGINES}"
|
- "--pdfengines-embed-engines=${PDFENGINES_EMBED_ENGINES}"
|
||||||
- "--pdfengines-embed-metadata-engines=${PDFENGINES_EMBED_METADATA_ENGINES}"
|
- "--pdfengines-embed-metadata-engines=${PDFENGINES_EMBED_METADATA_ENGINES}"
|
||||||
|
- "--pdfengines-factur-x-engines=${PDFENGINES_FACTUR_X_ENGINES}"
|
||||||
- "--pdfengines-disable-routes=${PDFENGINES_DISABLE_ROUTES}"
|
- "--pdfengines-disable-routes=${PDFENGINES_DISABLE_ROUTES}"
|
||||||
- "--prometheus-namespace=${PROMETHEUS_NAMESPACE}"
|
- "--prometheus-namespace=${PROMETHEUS_NAMESPACE}"
|
||||||
- "--prometheus-collect-interval=${PROMETHEUS_COLLECT_INTERVAL}"
|
- "--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/api v1.54.2
|
||||||
github.com/moby/moby/client v0.4.1
|
github.com/moby/moby/client v0.4.1
|
||||||
github.com/prometheus/client_golang v1.23.2
|
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/spf13/pflag v1.0.10
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/testcontainers/testcontainers-go v0.42.0
|
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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/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.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.4/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
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 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||||
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
|
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
|
||||||
|
|||||||
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"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"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].
|
// 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
|
// Exec executes the command and waits for its completion or until the context
|
||||||
// is done. In any case, it kills the unix process and all its children.
|
// is done. In any case, it kills the unix process and all its children.
|
||||||
|
//
|
||||||
|
// When the context carries an active trace span, Exec records a
|
||||||
|
// "process.exec" client span around the execution. It is the single
|
||||||
|
// instrumentation point for every short-lived external binary (soffice, pdftk,
|
||||||
|
// qpdf, exiftool, pdfcpu). The span is skipped when there is no active parent,
|
||||||
|
// so process starts performed off the request path do not emit orphan roots.
|
||||||
func (cmd *Cmd) Exec() (int, error) {
|
func (cmd *Cmd) Exec() (int, error) {
|
||||||
if cmd.ctx == nil {
|
if cmd.ctx == nil {
|
||||||
return 10, errors.New("nil context")
|
return 10, errors.New("nil context")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var span trace.Span
|
||||||
|
if trace.SpanContextFromContext(cmd.ctx).IsValid() {
|
||||||
|
_, span = Tracer().Start(cmd.ctx, "process.exec",
|
||||||
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
|
trace.WithAttributes(semconv.ProcessExecutableName(filepath.Base(cmd.process.Path))),
|
||||||
|
)
|
||||||
|
defer span.End()
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := cmd.exec()
|
||||||
|
|
||||||
|
if span != nil {
|
||||||
|
span.SetAttributes(attribute.Int("process.exit.code", code))
|
||||||
|
if err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
span.SetAttributes(semconv.ErrorTypeKey.String(execErrorType(cmd.ctx)))
|
||||||
|
} else {
|
||||||
|
span.SetStatus(codes.Ok, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return code, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// exec runs the command and returns its exit code and error.
|
||||||
|
func (cmd *Cmd) exec() (int, error) {
|
||||||
err := cmd.Start()
|
err := cmd.Start()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if cmd.process.ProcessState == nil {
|
if cmd.process.ProcessState == nil {
|
||||||
@@ -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.
|
// pipeOutput creates logs entries according to the process stdout and stderr.
|
||||||
// It does nothing if the logging level is not debug.
|
// It does nothing if the logging level is not debug.
|
||||||
func (cmd *Cmd) pipeOutput() error {
|
func (cmd *Cmd) pipeOutput() error {
|
||||||
|
|||||||
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.
|
// 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
|
// #nosec: G115
|
||||||
isTerminal := term.IsTerminal(int(os.Stdout.Fd()))
|
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.Key = "severity"
|
||||||
a.Value = slog.StringValue(gcpSeverity(l))
|
a.Value = slog.StringValue(gcpSeverity(l))
|
||||||
default:
|
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.
|
// 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/.
|
// See https://opentelemetry.io/.
|
||||||
package otel
|
package otel
|
||||||
|
|||||||
@@ -14,33 +14,63 @@ import (
|
|||||||
"go.opentelemetry.io/otel/propagation"
|
"go.opentelemetry.io/otel/propagation"
|
||||||
"go.opentelemetry.io/otel/sdk/log"
|
"go.opentelemetry.io/otel/sdk/log"
|
||||||
"go.opentelemetry.io/otel/sdk/metric"
|
"go.opentelemetry.io/otel/sdk/metric"
|
||||||
|
"go.opentelemetry.io/otel/sdk/metric/exemplar"
|
||||||
"go.opentelemetry.io/otel/sdk/resource"
|
"go.opentelemetry.io/otel/sdk/resource"
|
||||||
"go.opentelemetry.io/otel/sdk/trace"
|
"go.opentelemetry.io/otel/sdk/trace"
|
||||||
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
|
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// buildResource assembles the OpenTelemetry resource shared by the tracer,
|
||||||
|
// meter, and logger providers. Detection is best-effort: a detector or merge
|
||||||
|
// failure is logged and the build proceeds with whatever was gathered, so a
|
||||||
|
// flaky environment never prevents telemetry from starting.
|
||||||
|
func buildResource(ctx context.Context, logger *slog.Logger, serviceName, serviceVersion string) *resource.Resource {
|
||||||
|
base := resource.NewWithAttributes(
|
||||||
|
semconv.SchemaURL,
|
||||||
|
semconv.ServiceName(serviceName),
|
||||||
|
semconv.ServiceVersion(serviceVersion),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Granular detectors only. The WithProcess() bundle is deliberately omitted
|
||||||
|
// because it adds process.command_args/process.command_line, which can carry
|
||||||
|
// proxy credentials and host-resolver rules passed on the command line.
|
||||||
|
detected, err := resource.New(ctx,
|
||||||
|
resource.WithFromEnv(),
|
||||||
|
resource.WithTelemetrySDK(),
|
||||||
|
resource.WithHost(),
|
||||||
|
resource.WithHostID(),
|
||||||
|
resource.WithOS(),
|
||||||
|
resource.WithContainer(),
|
||||||
|
resource.WithProcessPID(),
|
||||||
|
resource.WithProcessExecutableName(),
|
||||||
|
resource.WithProcessExecutablePath(),
|
||||||
|
resource.WithProcessRuntimeName(),
|
||||||
|
resource.WithProcessRuntimeVersion(),
|
||||||
|
resource.WithProcessRuntimeDescription(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnContext(ctx, fmt.Sprintf("partially detect OpenTelemetry resource: %s", err))
|
||||||
|
}
|
||||||
|
if detected == nil {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
merged, err := resource.Merge(detected, base)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnContext(ctx, fmt.Sprintf("merge OpenTelemetry resource: %s", err))
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
// InitTracerProvider initializes the OpenTelemetry tracer provider.
|
// InitTracerProvider initializes the OpenTelemetry tracer provider.
|
||||||
func InitTracerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, err error) {
|
func InitTracerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, err error) {
|
||||||
initOtelLogger(logger)
|
initOtelLogger(logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
hostname, err := os.Hostname()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get hostname: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := resource.Merge(
|
res := buildResource(ctx, logger, serviceName, serviceVersion)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
traceOpts := []trace.TracerProviderOption{
|
traceOpts := []trace.TracerProviderOption{
|
||||||
trace.WithResource(res),
|
trace.WithResource(res),
|
||||||
@@ -71,27 +101,13 @@ func InitMeterProvider(logger *slog.Logger, serviceName, serviceVersion string)
|
|||||||
initOtelLogger(logger)
|
initOtelLogger(logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
hostname, err := os.Hostname()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get hostname: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := resource.Merge(
|
res := buildResource(ctx, logger, serviceName, serviceVersion)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
metricOpts := []metric.Option{
|
metricOpts := []metric.Option{
|
||||||
metric.WithResource(res),
|
metric.WithResource(res),
|
||||||
}
|
}
|
||||||
|
metricOpts = append(metricOpts, exemplarFilterOptions()...)
|
||||||
|
|
||||||
metricReader, err := autoexport.NewMetricReader(ctx)
|
metricReader, err := autoexport.NewMetricReader(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -108,28 +124,24 @@ func InitMeterProvider(logger *slog.Logger, serviceName, serviceVersion string)
|
|||||||
return meterProvider.Shutdown, nil
|
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.
|
// InitLoggerProvider initializes the OpenTelemetry logger provider.
|
||||||
func InitLoggerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, handler slog.Handler, err error) {
|
func InitLoggerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, handler slog.Handler, err error) {
|
||||||
initOtelLogger(logger)
|
initOtelLogger(logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
hostname, err := os.Hostname()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("get hostname: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := resource.Merge(
|
res := buildResource(ctx, logger, serviceName, serviceVersion)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
logOpts := []log.LoggerProviderOption{
|
logOpts := []log.LoggerProviderOption{
|
||||||
log.WithResource(res),
|
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
|
//nolint:dupl
|
||||||
type PdfEngineMock struct {
|
type PdfEngineMock struct {
|
||||||
MergeMock func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
|
MergeMock func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
|
||||||
SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
|
SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
|
||||||
FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath 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
|
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)
|
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)
|
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
|
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)
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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 {
|
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)
|
return engine.ReadBookmarksMock(ctx, logger, inputPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
|
func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts EncryptOptions) error {
|
||||||
return engine.EncryptMock(ctx, logger, inputPath, userPassword, ownerPassword)
|
return engine.EncryptMock(ctx, logger, inputPath, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
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)
|
return engine.RotateMock(ctx, logger, inputPath, angle, pages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (engine *PdfEngineMock) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX FacturX, inputPath string) error {
|
||||||
|
return engine.InjectFacturXXMPMock(ctx, logger, facturX, inputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (engine *PdfEngineMock) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
|
||||||
|
return engine.ReadPdfAConformanceMock(ctx, logger, inputPath)
|
||||||
|
}
|
||||||
|
|
||||||
// PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface.
|
// PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface.
|
||||||
type PdfEngineProviderMock struct {
|
type PdfEngineProviderMock struct {
|
||||||
PdfEngineMock func() (PdfEngine, error)
|
PdfEngineMock func() (PdfEngine, error)
|
||||||
@@ -152,13 +162,14 @@ func (p *ProcessMock) Healthy(logger *slog.Logger) bool {
|
|||||||
|
|
||||||
// ProcessSupervisorMock is a mock for the [ProcessSupervisor] interface.
|
// ProcessSupervisorMock is a mock for the [ProcessSupervisor] interface.
|
||||||
type ProcessSupervisorMock struct {
|
type ProcessSupervisorMock struct {
|
||||||
LaunchMock func() error
|
LaunchMock func() error
|
||||||
ShutdownMock func() error
|
ShutdownMock func() error
|
||||||
HealthyMock func() bool
|
HealthyMock func() bool
|
||||||
RunMock func(ctx context.Context, logger *slog.Logger, task func() error) error
|
RunMock func(ctx context.Context, logger *slog.Logger, task func() error) error
|
||||||
ReqQueueSizeMock func() int64
|
ReqQueueSizeMock func() int64
|
||||||
RestartsCountMock func() int64
|
RestartsCountMock func() int64
|
||||||
ActiveTasksCountMock func() int64
|
ActiveTasksCountMock func() int64
|
||||||
|
ConversionsSinceRestartMock func() int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ProcessSupervisorMock) Launch() error {
|
func (s *ProcessSupervisorMock) Launch() error {
|
||||||
@@ -189,6 +200,10 @@ func (s *ProcessSupervisorMock) ActiveTasksCount() int64 {
|
|||||||
return s.ActiveTasksCountMock()
|
return s.ActiveTasksCountMock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ProcessSupervisorMock) ConversionsSinceRestart() int64 {
|
||||||
|
return s.ConversionsSinceRestartMock()
|
||||||
|
}
|
||||||
|
|
||||||
// MetricsProviderMock is a mock for the [MetricsProvider] interface.
|
// MetricsProviderMock is a mock for the [MetricsProvider] interface.
|
||||||
type MetricsProviderMock struct {
|
type MetricsProviderMock struct {
|
||||||
MetricsMock func() ([]Metric, error)
|
MetricsMock func() ([]Metric, error)
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ var (
|
|||||||
// ErrPdfRotateAngleNotSupported is returned when the rotation angle is
|
// ErrPdfRotateAngleNotSupported is returned when the rotation angle is
|
||||||
// not supported.
|
// not supported.
|
||||||
ErrPdfRotateAngleNotSupported = errors.New("rotation angle not supported")
|
ErrPdfRotateAngleNotSupported = errors.New("rotation angle not supported")
|
||||||
|
|
||||||
|
// ErrPdfFacturXValueNotSupported is returned when a Factur-X field value
|
||||||
|
// (e.g., an unknown conformance level or document type) is not supported.
|
||||||
|
ErrPdfFacturXValueNotSupported = errors.New("Factur-X value not supported")
|
||||||
)
|
)
|
||||||
|
|
||||||
// PdfEngineInvalidArgsError represents an error returned by a PDF engine when
|
// PdfEngineInvalidArgsError represents an error returned by a PDF engine when
|
||||||
@@ -143,6 +147,56 @@ type PdfFormats struct {
|
|||||||
PdfUa bool
|
PdfUa bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PdfPermissions gathers the document permissions enforced when a PDF is
|
||||||
|
// encrypted. Each field defaults to true (the action is allowed); set a field
|
||||||
|
// to false to restrict it. Restrictions are advisory: viewers honor them, but
|
||||||
|
// they are not cryptographically enforced once the document opens.
|
||||||
|
type PdfPermissions struct {
|
||||||
|
// AllowPrinting permits printing the document.
|
||||||
|
AllowPrinting bool
|
||||||
|
|
||||||
|
// AllowCopying permits extracting text and graphics.
|
||||||
|
AllowCopying bool
|
||||||
|
|
||||||
|
// AllowModifying permits changing the document content.
|
||||||
|
AllowModifying bool
|
||||||
|
|
||||||
|
// AllowAnnotating permits adding or modifying annotations.
|
||||||
|
AllowAnnotating bool
|
||||||
|
|
||||||
|
// AllowFillingForms permits filling in form fields.
|
||||||
|
AllowFillingForms bool
|
||||||
|
|
||||||
|
// AllowAssembling permits inserting, deleting, and rotating pages.
|
||||||
|
AllowAssembling bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restricted reports whether at least one permission is denied.
|
||||||
|
func (p PdfPermissions) Restricted() bool {
|
||||||
|
return !p.AllowPrinting ||
|
||||||
|
!p.AllowCopying ||
|
||||||
|
!p.AllowModifying ||
|
||||||
|
!p.AllowAnnotating ||
|
||||||
|
!p.AllowFillingForms ||
|
||||||
|
!p.AllowAssembling
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptOptions gathers the parameters for encrypting a PDF. An empty
|
||||||
|
// UserPassword with a set OwnerPassword produces an owner-only document: it
|
||||||
|
// opens without a password but enforces the [PdfPermissions].
|
||||||
|
type EncryptOptions struct {
|
||||||
|
// UserPassword is required to open the document. Empty means no open
|
||||||
|
// password.
|
||||||
|
UserPassword string
|
||||||
|
|
||||||
|
// OwnerPassword grants full access (lifts the permission restrictions).
|
||||||
|
// When empty, it defaults to UserPassword.
|
||||||
|
OwnerPassword string
|
||||||
|
|
||||||
|
// Permissions are the actions allowed when opened with the user password.
|
||||||
|
Permissions PdfPermissions
|
||||||
|
}
|
||||||
|
|
||||||
// Bookmark represents a node in the PDF document's outline
|
// Bookmark represents a node in the PDF document's outline
|
||||||
// (table of contents).
|
// (table of contents).
|
||||||
type Bookmark struct {
|
type Bookmark struct {
|
||||||
@@ -151,6 +205,59 @@ type Bookmark struct {
|
|||||||
Children []Bookmark `json:"children,omitempty"`
|
Children []Bookmark `json:"children,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// FacturXConformanceMinimum represents the MINIMUM Factur-X conformance level.
|
||||||
|
FacturXConformanceMinimum string = "MINIMUM"
|
||||||
|
|
||||||
|
// FacturXConformanceBasicWL represents the BASIC WL Factur-X conformance level.
|
||||||
|
FacturXConformanceBasicWL string = "BASIC WL"
|
||||||
|
|
||||||
|
// FacturXConformanceBasic represents the BASIC Factur-X conformance level.
|
||||||
|
FacturXConformanceBasic string = "BASIC"
|
||||||
|
|
||||||
|
// FacturXConformanceEN16931 represents the EN 16931 Factur-X conformance level.
|
||||||
|
FacturXConformanceEN16931 string = "EN 16931"
|
||||||
|
|
||||||
|
// FacturXConformanceExtended represents the EXTENDED Factur-X conformance level.
|
||||||
|
FacturXConformanceExtended string = "EXTENDED"
|
||||||
|
|
||||||
|
// FacturXConformanceXRechnung represents the XRECHNUNG Factur-X conformance level.
|
||||||
|
FacturXConformanceXRechnung string = "XRECHNUNG"
|
||||||
|
|
||||||
|
// FacturXDocumentTypeInvoice represents the INVOICE Factur-X document type.
|
||||||
|
FacturXDocumentTypeInvoice string = "INVOICE"
|
||||||
|
|
||||||
|
// FacturXDocumentTypeOrder represents the ORDER Factur-X document type.
|
||||||
|
FacturXDocumentTypeOrder string = "ORDER"
|
||||||
|
|
||||||
|
// FacturXDocumentTypeOrderResponse represents the ORDER_RESPONSE Factur-X document type.
|
||||||
|
FacturXDocumentTypeOrderResponse string = "ORDER_RESPONSE"
|
||||||
|
|
||||||
|
// FacturXDocumentTypeOrderChange represents the ORDER_CHANGE Factur-X document type.
|
||||||
|
FacturXDocumentTypeOrderChange string = "ORDER_CHANGE"
|
||||||
|
|
||||||
|
// FacturXDocumentFileName is the canonical name of the embedded XML invoice
|
||||||
|
// mandated by the Factur-X standard. Validators expect this exact name.
|
||||||
|
FacturXDocumentFileName string = "factur-x.xml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FacturX gathers the properties required by the Factur-X/ZUGFeRD standard for
|
||||||
|
// the document-level XMP metadata packet of a PDF/A-3.
|
||||||
|
type FacturX struct {
|
||||||
|
// ConformanceLevel is one of the FacturXConformance* values.
|
||||||
|
ConformanceLevel string
|
||||||
|
|
||||||
|
// DocumentType is one of the FacturXDocumentType* values.
|
||||||
|
DocumentType string
|
||||||
|
|
||||||
|
// DocumentFileName is the name of the embedded XML invoice. It is set
|
||||||
|
// internally to the canonical [FacturXDocumentFileName], not by the caller.
|
||||||
|
DocumentFileName string
|
||||||
|
|
||||||
|
// Version is the Factur-X version (e.g., "1.0").
|
||||||
|
Version string
|
||||||
|
}
|
||||||
|
|
||||||
// PdfEngine provides an interface for operations on PDFs. Implementations
|
// PdfEngine provides an interface for operations on PDFs. Implementations
|
||||||
// can use various tools like PDFtk, or implement functionality directly in
|
// can use various tools like PDFtk, or implement functionality directly in
|
||||||
// Go.
|
// Go.
|
||||||
@@ -190,11 +297,11 @@ type PdfEngine interface {
|
|||||||
// The bookmarks parameter represents the hierarchical tree of the outline.
|
// The bookmarks parameter represents the hierarchical tree of the outline.
|
||||||
WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
|
WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
|
||||||
|
|
||||||
// Encrypt adds password protection to a PDF file.
|
// Encrypt adds password protection and permission restrictions to a PDF
|
||||||
// The userPassword is required to open the document.
|
// file, as described by [EncryptOptions]. An empty user password with a set
|
||||||
// The ownerPassword provides full access to the document.
|
// owner password yields an owner-only document (opens without a password,
|
||||||
// If the ownerPassword is empty, it defaults to the userPassword.
|
// permissions enforced).
|
||||||
Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
|
Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts EncryptOptions) error
|
||||||
|
|
||||||
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
|
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
|
||||||
// without modifying the main PDF content.
|
// without modifying the main PDF content.
|
||||||
@@ -216,6 +323,18 @@ type PdfEngine interface {
|
|||||||
// Rotate rotates pages of a PDF file by the given angle (90, 180, 270).
|
// Rotate rotates pages of a PDF file by the given angle (90, 180, 270).
|
||||||
// If pages is empty, all pages are rotated.
|
// If pages is empty, all pages are rotated.
|
||||||
Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
|
Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
|
||||||
|
|
||||||
|
// InjectFacturXXMP injects Factur-X/ZUGFeRD XMP metadata into the
|
||||||
|
// document-level XMP packet (Catalog /Metadata stream) of a PDF/A-3. It
|
||||||
|
// registers the fx namespace, the four fx properties, and the matching
|
||||||
|
// PDF/A extension schema so the result stays PDF/A-valid.
|
||||||
|
InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX FacturX, inputPath string) error
|
||||||
|
|
||||||
|
// ReadPdfAConformance reads the PDF/A part and conformance (e.g., "3" and
|
||||||
|
// "B") from the document-level XMP packet (Catalog /Metadata stream,
|
||||||
|
// pdfaid:part and pdfaid:conformance). It returns empty strings when the
|
||||||
|
// document carries no PDF/A identification.
|
||||||
|
ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (part string, conformance string, err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PdfEngineProvider offers an interface to instantiate a [PdfEngine].
|
// PdfEngineProvider offers an interface to instantiate a [PdfEngine].
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
"go.opentelemetry.io/otel/codes"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrProcessAlreadyRestarting happens if the [ProcessSupervisor] is trying
|
// ErrProcessAlreadyRestarting happens if the [ProcessSupervisor] is trying
|
||||||
@@ -76,6 +80,10 @@ type ProcessSupervisor interface {
|
|||||||
|
|
||||||
// ActiveTasksCount returns the current number of active tasks.
|
// ActiveTasksCount returns the current number of active tasks.
|
||||||
ActiveTasksCount() int64
|
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-
|
// healthCheckCacheTTL caches successful health probe results so kubelet-
|
||||||
@@ -97,6 +105,7 @@ const healthFailureThreshold = 2
|
|||||||
|
|
||||||
type processSupervisor struct {
|
type processSupervisor struct {
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
engine string
|
||||||
process Process
|
process Process
|
||||||
maxReqLimit int64
|
maxReqLimit int64
|
||||||
maxQueueSize int64
|
maxQueueSize int64
|
||||||
@@ -128,14 +137,21 @@ type processSupervisor struct {
|
|||||||
idleStopChan chan struct{} // signal to stop the idle ticker goroutine
|
idleStopChan chan struct{} // signal to stop the idle ticker goroutine
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProcessSupervisor initializes a new [ProcessSupervisor].
|
// NewProcessSupervisor initializes a new [ProcessSupervisor]. engine names the
|
||||||
func NewProcessSupervisor(logger *slog.Logger, process Process, maxReqLimit, maxQueueSize, maxConcurrency int64, idleShutdownTimeout time.Duration) ProcessSupervisor {
|
// 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 {
|
if maxConcurrency < 1 {
|
||||||
maxConcurrency = 1
|
maxConcurrency = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if engine == "" {
|
||||||
|
engine = "process"
|
||||||
|
}
|
||||||
|
|
||||||
b := &processSupervisor{
|
b := &processSupervisor{
|
||||||
logger: logger,
|
logger: logger,
|
||||||
|
engine: engine,
|
||||||
process: process,
|
process: process,
|
||||||
semaphore: make(chan struct{}, maxConcurrency),
|
semaphore: make(chan struct{}, maxConcurrency),
|
||||||
maxReqLimit: maxReqLimit,
|
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 {
|
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
|
// Atomically check and increment the queue size to avoid the TOCTOU race
|
||||||
// originally reported in https://github.com/gotenberg/gotenberg/issues/951.
|
// originally reported in https://github.com/gotenberg/gotenberg/issues/951.
|
||||||
for {
|
for {
|
||||||
@@ -315,6 +345,7 @@ func (s *processSupervisor) Run(ctx context.Context, logger *slog.Logger, task f
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
endQueueWait()
|
||||||
err := s.runWithDeadline(ctx, task)
|
err := s.runWithDeadline(ctx, task)
|
||||||
|
|
||||||
if s.maybeRestartAfterTask(logger) {
|
if s.maybeRestartAfterTask(logger) {
|
||||||
@@ -453,8 +484,8 @@ func (s *processSupervisor) ensureStarted(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err := s.runWithDeadline(ctx, func() error {
|
err := s.tracedLaunch(ctx, "first_start", func() error {
|
||||||
return s.Launch()
|
return s.runWithDeadline(ctx, s.Launch)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("process first start: %w", err)
|
return fmt.Errorf("process first start: %w", err)
|
||||||
@@ -463,6 +494,27 @@ func (s *processSupervisor) ensureStarted(ctx context.Context) error {
|
|||||||
return nil
|
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
|
// ensureHealthy checks the underlying process health and triggers a
|
||||||
// synchronous restart if the process is unhealthy. Skips the check if a
|
// synchronous restart if the process is unhealthy. Skips the check if a
|
||||||
// restart is already in progress.
|
// 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...")
|
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)
|
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...")
|
s.logger.DebugContext(context.Background(), "max request limit reached, restarting eagerly...")
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
restartErr := s.doRestartLocked(context.Background())
|
restartErr := s.doRestartLocked(context.Background(), "max_requests")
|
||||||
s.restartMutex.Unlock()
|
s.restartMutex.Unlock()
|
||||||
if restartErr != nil {
|
if restartErr != nil {
|
||||||
s.logger.ErrorContext(context.Background(), fmt.Sprintf("process restart after task: %v", restartErr))
|
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
|
// doRestart coordinates a process restart, draining all active concurrent
|
||||||
// tasks before stopping and restarting the process.
|
// 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()
|
s.restartMutex.Lock()
|
||||||
defer s.restartMutex.Unlock()
|
defer s.restartMutex.Unlock()
|
||||||
|
|
||||||
return s.doRestartLocked(ctx)
|
return s.doRestartLocked(ctx, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
// doRestartLocked performs the restart drain logic. The caller must hold restartMutex.
|
// 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)
|
s.isRestarting.Store(true)
|
||||||
defer s.isRestarting.Store(false)
|
defer s.isRestarting.Store(false)
|
||||||
|
|
||||||
@@ -538,8 +590,8 @@ func (s *processSupervisor) doRestartLocked(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err := s.runWithDeadline(ctx, func() error {
|
err := s.tracedLaunch(ctx, reason, func() error {
|
||||||
return s.restart()
|
return s.runWithDeadline(ctx, s.restart)
|
||||||
})
|
})
|
||||||
|
|
||||||
for range acquired {
|
for range acquired {
|
||||||
@@ -577,6 +629,13 @@ func (s *processSupervisor) ActiveTasksCount() int64 {
|
|||||||
return s.activeTasks.Load()
|
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.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ ProcessSupervisor = (*processSupervisor)(nil)
|
_ 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 {
|
if tc.firstStartSet {
|
||||||
ps.firstStart.Store(true)
|
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()
|
err := ps.Shutdown()
|
||||||
|
|
||||||
if !tc.expectError && err != nil {
|
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()
|
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 {
|
if tc.initiallyStarted {
|
||||||
ps.firstStart.Store(true)
|
ps.firstStart.Store(true)
|
||||||
}
|
}
|
||||||
@@ -229,7 +229,7 @@ func TestProcessSupervisor_Healthy_ConsecutiveFailures(t *testing.T) {
|
|||||||
HealthyMock: func(_ *slog.Logger) bool { return processHealthy.Load() },
|
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)
|
ps.firstStart.Store(true)
|
||||||
|
|
||||||
processHealthy.Store(false)
|
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)
|
ps.firstStart.Store(true)
|
||||||
|
|
||||||
for range 5 {
|
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)
|
ps.firstStart.Store(true)
|
||||||
|
|
||||||
processHealthy.Store(false)
|
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 {
|
if tc.initiallyStarted {
|
||||||
ps.firstStart.Store(true)
|
ps.firstStart.Store(true)
|
||||||
}
|
}
|
||||||
@@ -573,7 +573,7 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
|
|||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.scenario, func(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)
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||||
if tc.ctxDone {
|
if tc.ctxDone {
|
||||||
@@ -607,7 +607,7 @@ func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
|
|||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, 0).(*processSupervisor)
|
||||||
|
|
||||||
// Simulating a lock.
|
// Simulating a lock.
|
||||||
ps.semaphore <- struct{}{}
|
ps.semaphore <- struct{}{}
|
||||||
@@ -668,7 +668,7 @@ func TestProcessSupervisor_QueueSizeCAS(t *testing.T) {
|
|||||||
|
|
||||||
maxQueueSize := int64(50)
|
maxQueueSize := int64(50)
|
||||||
// maxConcurrency=1 so all goroutines block on the semaphore, exercising queue logic.
|
// 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.
|
// Simulating a lock so that all goroutines queue up.
|
||||||
ps.semaphore <- struct{}{}
|
ps.semaphore <- struct{}{}
|
||||||
@@ -722,7 +722,7 @@ func TestProcessSupervisor_QueueSizeIncludesActiveTasks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// maxQueueSize=1, maxConcurrency=1: only one request at a time.
|
// 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)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
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)
|
ps.restartsCounter.Store(tc.initialRestartsCount)
|
||||||
|
|
||||||
for i := 0; i < tc.restartAttempts; i++ {
|
for i := 0; i < tc.restartAttempts; i++ {
|
||||||
@@ -844,7 +844,7 @@ func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
maxConcurrency := int64(3)
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -906,7 +906,7 @@ func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
maxConcurrency := int64(3)
|
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)
|
ps.firstStart.Store(true)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
@@ -963,7 +963,7 @@ func TestProcessSupervisor_IdleShutdown(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
idleTimeout := 50 * time.Millisecond
|
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()
|
ctx := context.Background()
|
||||||
err := ps.Run(ctx, logger, func() error {
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -1069,7 +1069,7 @@ func TestProcessSupervisor_IdleShutdownSkippedWhenActive(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
idleTimeout := 50 * time.Millisecond
|
idleTimeout := 50 * time.Millisecond
|
||||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, idleTimeout)
|
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, idleTimeout)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
go func() {
|
go func() {
|
||||||
@@ -1091,3 +1091,70 @@ func TestProcessSupervisor_IdleShutdownSkippedWhenActive(t *testing.T) {
|
|||||||
|
|
||||||
close(taskDone)
|
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"
|
DebugLoggingLevel = "debug"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
LowerLevelCase = "lower"
|
||||||
|
UpperLevelCase = "upper"
|
||||||
|
)
|
||||||
|
|
||||||
// TelemetryConfig gathers the configuration data for Gotenberg's telemetry.
|
// TelemetryConfig gathers the configuration data for Gotenberg's telemetry.
|
||||||
type TelemetryConfig struct {
|
type TelemetryConfig struct {
|
||||||
ServiceName string
|
ServiceName string
|
||||||
@@ -38,6 +43,7 @@ type TelemetryConfig struct {
|
|||||||
LogFieldsPrefix string
|
LogFieldsPrefix string
|
||||||
LogStdFormat string
|
LogStdFormat string
|
||||||
LogStdEnableGcpFields bool
|
LogStdEnableGcpFields bool
|
||||||
|
LogStdLevelCase string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cfg TelemetryConfig) slogLevel() slog.Level {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +108,7 @@ func (cfg TelemetryConfig) Validate() error {
|
|||||||
func StartTelemetry(cfg TelemetryConfig) (shutdown func(context.Context) error, err error) {
|
func StartTelemetry(cfg TelemetryConfig) (shutdown func(context.Context) error, err error) {
|
||||||
var handlers []slog.Handler
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get standard logger handler: %w", err)
|
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 {
|
if bodyLimit != 0 && newTotal > bodyLimit {
|
||||||
return WrapError(
|
return WrapError(
|
||||||
fmt.Errorf("body limit reached (> %d)", bodyLimit),
|
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
|
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.
|
// OriginalFilename returns the original filename associated with a disk path.
|
||||||
// If no mapping exists, it falls back to [filepath.Base].
|
// If no mapping exists, it falls back to [filepath.Base].
|
||||||
func (ctx *Context) OriginalFilename(diskPath string) string {
|
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 represents the form field name for the stamp file.
|
||||||
StampFormField string = "stamp"
|
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
|
// FormData is a helper for validating and hydrating values from a
|
||||||
@@ -475,13 +479,28 @@ func (form *FormData) Stamp(target *string) *FormData {
|
|||||||
return form
|
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
|
// paths bind the absolute paths of form data files, according to a list of
|
||||||
// file extensions, to a string slice variable.
|
// 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 {
|
func (form *FormData) paths(extensions []string, target *[]string) *FormData {
|
||||||
embeds, ok := form.filesByField[EmbedsFormField]
|
embeds, ok := form.filesByField[EmbedsFormField]
|
||||||
watermarks, wmOk := form.filesByField[WatermarkFormField]
|
watermarks, wmOk := form.filesByField[WatermarkFormField]
|
||||||
stamps, stOk := form.filesByField[StampFormField]
|
stamps, stOk := form.filesByField[StampFormField]
|
||||||
|
facturxXmls, fxOk := form.filesByField[FacturXXmlFormField]
|
||||||
|
|
||||||
// Collect (originalFilename, diskPath) pairs so that we can sort by
|
// Collect (originalFilename, diskPath) pairs so that we can sort by
|
||||||
// original filename rather than by UUID-based disk name.
|
// original filename rather than by UUID-based disk name.
|
||||||
@@ -505,6 +524,10 @@ func (form *FormData) paths(extensions []string, target *[]string) *FormData {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if fxOk && slices.Contains(facturxXmls, path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
for _, ext := range extensions {
|
for _, ext := range extensions {
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/228.
|
// See https://github.com/gotenberg/gotenberg/issues/228.
|
||||||
if strings.ToLower(filepath.Ext(filename)) == ext {
|
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)
|
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) {
|
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) {
|
if errors.Is(err, gotenberg.ErrFiltered) {
|
||||||
@@ -51,27 +51,27 @@ func ParseError(err error) (int, string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
|
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) {
|
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) {
|
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) {
|
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) {
|
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) {
|
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 {
|
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 {
|
type browser interface {
|
||||||
gotenberg.Process
|
gotenberg.Process
|
||||||
pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) 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) error
|
screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions, aggregate *networkAggregate) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type browserArguments struct {
|
type browserArguments struct {
|
||||||
@@ -314,10 +314,10 @@ func (b *chromiumBrowser) Healthy(logger *slog.Logger) bool {
|
|||||||
return true
|
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
|
// Note: no error wrapping because it leaks on errors we want to display to
|
||||||
// the end user.
|
// 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(),
|
network.Enable(),
|
||||||
fetch.Enable(),
|
fetch.Enable(),
|
||||||
runtime.Enable(),
|
runtime.Enable(),
|
||||||
@@ -334,16 +334,16 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, out
|
|||||||
waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector),
|
waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector),
|
||||||
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
|
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
|
||||||
// PDF specific.
|
// PDF specific.
|
||||||
printToPdfActionFunc(logger, outputPath, options),
|
printToPdfActionFunc(ctx, logger, outputPath, options),
|
||||||
// Teardown.
|
// Teardown.
|
||||||
page.Close(),
|
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
|
// Note: no error wrapping because it leaks on errors we want to display to
|
||||||
// the end user.
|
// 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(),
|
network.Enable(),
|
||||||
fetch.Enable(),
|
fetch.Enable(),
|
||||||
runtime.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() {
|
if !b.isStarted.Load() {
|
||||||
return errors.New("browser not started, cannot handle tasks")
|
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)
|
taskCtx, taskCancel := chromedp.NewContext(timeoutCtx)
|
||||||
defer taskCancel()
|
defer taskCancel()
|
||||||
|
|
||||||
|
// Accumulate per-conversion network activity for telemetry.
|
||||||
|
listenForNetworkActivity(taskCtx, aggregate)
|
||||||
|
|
||||||
// We validate all other requests against our allowed / deny lists.
|
// 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
|
// If a request does not pass the validation, we make it fail. It also set
|
||||||
// the extra HTTP headers, if any.
|
// the extra HTTP headers, if any.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -101,12 +102,17 @@ type Chromium struct {
|
|||||||
supervisor gotenberg.ProcessSupervisor
|
supervisor gotenberg.ProcessSupervisor
|
||||||
engine gotenberg.PdfEngine
|
engine gotenberg.PdfEngine
|
||||||
|
|
||||||
|
version string
|
||||||
|
versionOnce sync.Once
|
||||||
|
|
||||||
reqsCounter metric.Int64Counter
|
reqsCounter metric.Int64Counter
|
||||||
errsCounter metric.Int64Counter
|
errsCounter metric.Int64Counter
|
||||||
conversionDurationCounter metric.Float64Histogram
|
conversionDurationCounter metric.Float64Histogram
|
||||||
queueWaitDurationCounter metric.Float64Histogram
|
queueWaitDurationCounter metric.Float64Histogram
|
||||||
pdfOutputSizeCounter metric.Int64Histogram
|
pdfOutputSizeCounter metric.Int64Histogram
|
||||||
imageOutputSizeCounter metric.Int64Histogram
|
imageOutputSizeCounter metric.Int64Histogram
|
||||||
|
networkRequestsCounter metric.Int64Counter
|
||||||
|
networkBytesCounter metric.Int64Histogram
|
||||||
}
|
}
|
||||||
|
|
||||||
// Options are the common options for all conversions.
|
// 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")
|
binPath, ok := os.LookupEnv("CHROMIUM_BIN_PATH")
|
||||||
if !ok {
|
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")
|
hyphenDataDirPath, ok := os.LookupEnv("CHROMIUM_HYPHEN_DATA_DIR_PATH")
|
||||||
if !ok {
|
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{
|
mod.args = browserArguments{
|
||||||
@@ -517,7 +523,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
|||||||
|
|
||||||
// Process.
|
// Process.
|
||||||
mod.browser = newChromiumBrowser(mod.args)
|
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.
|
// PDF Engine.
|
||||||
provider, err := ctx.Module(new(gotenberg.PdfEngineProvider))
|
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)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,12 +668,12 @@ func (mod *Chromium) Validate() error {
|
|||||||
|
|
||||||
_, err := os.Stat(mod.args.binPath)
|
_, err := os.Stat(mod.args.binPath)
|
||||||
if os.IsNotExist(err) {
|
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)
|
_, err = os.Stat(mod.args.hyphenDataDirPath)
|
||||||
if os.IsNotExist(err) {
|
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
|
return nil
|
||||||
@@ -697,19 +721,46 @@ func (mod *Chromium) Stop(ctx context.Context) error {
|
|||||||
|
|
||||||
// Debug returns additional debug data.
|
// Debug returns additional debug data.
|
||||||
func (mod *Chromium) Debug() map[string]any {
|
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
|
// detectVersion resolves the Chromium version once, preferring the value
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
// 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()
|
cmd := exec.Command(mod.args.binPath, "--version") //nolint:gosec
|
||||||
if err != nil {
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
debug["version"] = err.Error()
|
|
||||||
return debug
|
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 append(attrs, extra...)
|
||||||
return debug
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metrics returns the metrics.
|
// Metrics returns the metrics.
|
||||||
@@ -799,19 +850,32 @@ func (mod *Chromium) Routes() ([]api.Route, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pdf converts a URL to PDF.
|
// Pdf converts a URL to PDF.
|
||||||
|
//
|
||||||
|
//nolint:dupl
|
||||||
func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
|
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",
|
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Pdf",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)),
|
trace.WithAttributes(mod.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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()
|
start := time.Now()
|
||||||
var conversionStart time.Time
|
var conversionStart time.Time
|
||||||
|
|
||||||
|
aggregate := newNetworkAggregate()
|
||||||
err := mod.supervisor.Run(ctx, logger, func() error {
|
err := mod.supervisor.Run(ctx, logger, func() error {
|
||||||
conversionStart = time.Now()
|
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()
|
end := time.Now()
|
||||||
@@ -824,21 +888,12 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
|
|||||||
status = "error"
|
status = "error"
|
||||||
}
|
}
|
||||||
|
|
||||||
reason := "unknown"
|
reason := chromiumErrorType(err, "chromium_unavailable")
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
||||||
mod.errsCounter.Add(ctx, 1, metric.WithAttributes(
|
mod.errsCounter.Add(ctx, 1, metric.WithAttributes(
|
||||||
attribute.String("reason", reason),
|
attribute.String("reason", reason),
|
||||||
))
|
))
|
||||||
|
gotenberg.SpanErrorType(span, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !conversionStart.IsZero() {
|
if !conversionStart.IsZero() {
|
||||||
@@ -862,9 +917,12 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
|
|||||||
attribute.String("status", status),
|
attribute.String("status", status),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
mod.recordNetwork(ctx, span, aggregate)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
|
||||||
mod.pdfOutputSizeCounter.Record(ctx, fileInfo.Size())
|
mod.pdfOutputSizeCounter.Record(ctx, fileInfo.Size())
|
||||||
|
span.SetAttributes(attribute.Int64("gotenberg.conversion.output.bytes", fileInfo.Size()))
|
||||||
}
|
}
|
||||||
|
|
||||||
span.SetStatus(codes.Ok, "")
|
span.SetStatus(codes.Ok, "")
|
||||||
@@ -876,19 +934,28 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
|
|||||||
return err
|
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 {
|
func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Screenshot",
|
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Screenshot",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)),
|
trace.WithAttributes(mod.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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()
|
start := time.Now()
|
||||||
var conversionStart time.Time
|
var conversionStart time.Time
|
||||||
|
|
||||||
|
aggregate := newNetworkAggregate()
|
||||||
err := mod.supervisor.Run(ctx, logger, func() error {
|
err := mod.supervisor.Run(ctx, logger, func() error {
|
||||||
conversionStart = time.Now()
|
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()
|
end := time.Now()
|
||||||
@@ -901,23 +968,12 @@ func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, o
|
|||||||
status = "error"
|
status = "error"
|
||||||
}
|
}
|
||||||
|
|
||||||
reason := "unknown"
|
reason := chromiumErrorType(err, "chromium_maximum_queue_size_exceeded")
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
||||||
mod.errsCounter.Add(ctx, 1, metric.WithAttributes(
|
mod.errsCounter.Add(ctx, 1, metric.WithAttributes(
|
||||||
attribute.String("reason", reason),
|
attribute.String("reason", reason),
|
||||||
))
|
))
|
||||||
|
gotenberg.SpanErrorType(span, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !conversionStart.IsZero() {
|
if !conversionStart.IsZero() {
|
||||||
@@ -941,6 +997,8 @@ func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, o
|
|||||||
attribute.String("status", status),
|
attribute.String("status", status),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
mod.recordNetwork(ctx, span, aggregate)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
|
||||||
mod.imageOutputSizeCounter.Record(ctx, fileInfo.Size())
|
mod.imageOutputSizeCounter.Record(ctx, fileInfo.Size())
|
||||||
@@ -955,6 +1013,86 @@ func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, o
|
|||||||
return err
|
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.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ gotenberg.Module = (*Chromium)(nil)
|
_ 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"
|
"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 {
|
type eventRequestPausedOptions struct {
|
||||||
allowList, denyList []*regexp2.Regexp
|
allowList, denyList []*regexp2.Regexp
|
||||||
denyPrivateIPs bool
|
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.
|
// browserMock is a mock for the [browser] interface.
|
||||||
type browserMock struct {
|
type browserMock struct {
|
||||||
gotenberg.ProcessMock
|
gotenberg.ProcessMock
|
||||||
pdfMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) 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) 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 {
|
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)
|
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 {
|
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)
|
return b.screenshotMock(ctx, logger, url, outputPath, options, aggregate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interface guards.
|
// 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)
|
mode := pdfengines.FormDataPdfSplitMode(form, false)
|
||||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||||
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
||||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||||
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
||||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
||||||
@@ -454,6 +454,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||||
|
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||||
|
|
||||||
var url string
|
var url string
|
||||||
err := form.
|
err := form.
|
||||||
@@ -477,7 +478,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("validate stamp: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("convert URL to PDF: %w", err)
|
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)
|
mode := pdfengines.FormDataPdfSplitMode(form, false)
|
||||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||||
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
||||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||||
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
||||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
||||||
@@ -542,6 +543,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||||
|
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||||
|
|
||||||
var inputPath string
|
var inputPath string
|
||||||
err := form.
|
err := form.
|
||||||
@@ -562,7 +564,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
|
|
||||||
url := fmt.Sprintf("file://%s", inputPath)
|
url := fmt.Sprintf("file://%s", inputPath)
|
||||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("convert HTML to PDF: %w", err)
|
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)
|
mode := pdfengines.FormDataPdfSplitMode(form, false)
|
||||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||||
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
||||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||||
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
||||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
||||||
@@ -624,6 +626,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||||
|
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
inputPath string
|
inputPath string
|
||||||
@@ -653,7 +656,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
}
|
}
|
||||||
|
|
||||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("convert markdown to PDF: %w", err)
|
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
|
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")
|
outputPath := ctx.GeneratePath(".pdf")
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/1130.
|
// See https://github.com/gotenberg/gotenberg/issues/1130.
|
||||||
filename := ctx.OutputFilename(outputPath)
|
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)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -865,6 +878,8 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
|||||||
return fmt.Errorf("rotate PDFs: %w", err)
|
return fmt.Errorf("rotate PDFs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pdfFormats = pdfengines.FacturXPdfFormats(ctx, engine, facturX, pdfFormats, true, nil)
|
||||||
|
|
||||||
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert PDF(s): %w", err)
|
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)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,104 +14,148 @@ import (
|
|||||||
"github.com/chromedp/cdproto/network"
|
"github.com/chromedp/cdproto/network"
|
||||||
"github.com/chromedp/cdproto/page"
|
"github.com/chromedp/cdproto/page"
|
||||||
"github.com/chromedp/chromedp"
|
"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 {
|
return func(ctx context.Context) error {
|
||||||
paperHeight := options.PaperHeight
|
// ctx is the chromedp task context, derived from context.Background(),
|
||||||
pageRanges := options.PageRanges
|
// 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 {
|
err := func() error {
|
||||||
logger.DebugContext(ctx, "single page PDF")
|
paperHeight := options.PaperHeight
|
||||||
|
pageRanges := options.PageRanges
|
||||||
|
|
||||||
_, _, _, _, _, cssContentSize, err := page.GetLayoutMetrics().Do(ctx)
|
if options.SinglePage {
|
||||||
if err != nil {
|
logger.DebugContext(ctx, "single page PDF")
|
||||||
return fmt.Errorf("get layout metrics: %w", err)
|
|
||||||
|
_, _, _, _, _, 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.
|
printToPdf := page.PrintToPDF().
|
||||||
// See https://issues.chromium.org/issues/40267771#comment14.
|
WithTransferMode(page.PrintToPDFTransferModeReturnAsStream).
|
||||||
// We add top and bottom margins so that the content area
|
WithLandscape(options.Landscape).
|
||||||
// is large enough to fit the entire content.
|
WithPrintBackground(options.PrintBackground).
|
||||||
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
|
WithScale(options.Scale).
|
||||||
pageRanges = "1" // little dirty hack to avoid leftovers.
|
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().
|
hasCustomHeaderFooter := options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate ||
|
||||||
WithTransferMode(page.PrintToPDFTransferModeReturnAsStream).
|
options.FooterTemplate != DefaultPdfOptions().FooterTemplate
|
||||||
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 ||
|
if !hasCustomHeaderFooter {
|
||||||
options.FooterTemplate != DefaultPdfOptions().FooterTemplate
|
logger.DebugContext(ctx, "no custom header nor footer")
|
||||||
|
|
||||||
if !hasCustomHeaderFooter {
|
printToPdf = printToPdf.WithDisplayHeaderFooter(false)
|
||||||
logger.DebugContext(ctx, "no custom header nor footer")
|
} 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 {
|
} else {
|
||||||
logger.DebugContext(ctx, "with custom header and/or footer")
|
span.SetStatus(codes.Ok, "")
|
||||||
|
|
||||||
printToPdf = printToPdf.
|
|
||||||
WithDisplayHeaderFooter(true).
|
|
||||||
WithHeaderTemplate(options.HeaderTemplate).
|
|
||||||
WithFooterTemplate(options.FooterTemplate)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugContext(ctx, fmt.Sprintf("print to PDF with: %+v", printToPdf))
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_, stream, err := printToPdf.Do(ctx)
|
// printToPdfAttrs derives bounded, low-cardinality attributes from the print
|
||||||
if err != nil {
|
// options. Raw header/footer templates and page ranges are reduced to booleans
|
||||||
return fmt.Errorf("print to PDF: %w", err)
|
// to avoid leaking document content and exploding cardinality.
|
||||||
}
|
func printToPdfAttrs(options PdfOptions) []attribute.KeyValue {
|
||||||
|
return []attribute.KeyValue{
|
||||||
reader := &streamReader{
|
attribute.Bool("gotenberg.chromium.print.landscape", options.Landscape),
|
||||||
ctx: ctx,
|
attribute.Bool("gotenberg.chromium.print.print_background", options.PrintBackground),
|
||||||
handle: stream,
|
attribute.Float64("gotenberg.chromium.print.scale", options.Scale),
|
||||||
r: nil,
|
attribute.Float64("gotenberg.chromium.print.paper_width", options.PaperWidth),
|
||||||
pos: 0,
|
attribute.Float64("gotenberg.chromium.print.paper_height", options.PaperHeight),
|
||||||
eof: false,
|
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),
|
||||||
defer func() {
|
attribute.Bool("gotenberg.chromium.print.has_page_ranges", options.PageRanges != ""),
|
||||||
err = reader.Close()
|
attribute.Bool("gotenberg.chromium.print.has_header", options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate),
|
||||||
if err != nil {
|
attribute.Bool("gotenberg.chromium.print.has_footer", options.FooterTemplate != DefaultPdfOptions().FooterTemplate),
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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"
|
"os/exec"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.opentelemetry.io/otel/codes"
|
"go.opentelemetry.io/otel/codes"
|
||||||
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
||||||
"go.opentelemetry.io/otel/trace"
|
"go.opentelemetry.io/otel/trace"
|
||||||
@@ -159,6 +161,9 @@ func buildExifToolWriteArgs(metadata map[string]any) ([]string, error) {
|
|||||||
// [gotenberg.PdfEngine] interface.
|
// [gotenberg.PdfEngine] interface.
|
||||||
type ExifTool struct {
|
type ExifTool struct {
|
||||||
binPath string
|
binPath string
|
||||||
|
|
||||||
|
version string
|
||||||
|
versionOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
// Descriptor returns [ExifTool]'s module descriptor.
|
// Descriptor returns [ExifTool]'s module descriptor.
|
||||||
@@ -193,26 +198,53 @@ func (engine *ExifTool) Validate() error {
|
|||||||
|
|
||||||
// Debug returns additional debug data.
|
// Debug returns additional debug data.
|
||||||
func (engine *ExifTool) Debug() map[string]any {
|
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
|
// detectVersion resolves the ExifTool version once, preferring the value
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
// 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()
|
cmd := exec.Command(engine.binPath, "-ver") //nolint:gosec
|
||||||
if err != nil {
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
debug["version"] = err.Error()
|
|
||||||
return debug
|
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 append(attrs, extra...)
|
||||||
return debug
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge is not available in this implementation.
|
// Merge is not available in this implementation.
|
||||||
func (engine *ExifTool) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
func (engine *ExifTool) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Merge",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Merge",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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) {
|
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",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Split",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *ExifTool) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Flatten",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Flatten",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Convert",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Convert",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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) {
|
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.ReadMetadata",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.ReadMetadata",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.WriteMetadata",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.WriteMetadata",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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) {
|
func (engine *ExifTool) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.PageCount",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.PageCount",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *ExifTool) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.WriteBookmarks",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.WriteBookmarks",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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) {
|
func (engine *ExifTool) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.ReadBookmarks",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.ReadBookmarks",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -449,10 +481,10 @@ func (engine *ExifTool) ReadBookmarks(ctx context.Context, logger *slog.Logger,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt is not available in this implementation.
|
// 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",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Encrypt",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.EmbedFiles",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.EmbedFiles",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *ExifTool) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Watermark",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Watermark",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *ExifTool) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Stamp",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Stamp",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *ExifTool) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Rotate",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Rotate",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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)
|
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.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ gotenberg.Module = (*ExifTool)(nil)
|
_ gotenberg.Module = (*ExifTool)(nil)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ var (
|
|||||||
ErrUnoException = errors.New("uno exception")
|
ErrUnoException = errors.New("uno exception")
|
||||||
|
|
||||||
// ErrRuntimeException happens when unoconverter returns exit code 6.
|
// 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
|
// ErrCoreDumped happens randomly; sometimes a conversion will work as
|
||||||
// expected, and some other time the same conversion will fail.
|
// expected, and some other time the same conversion will fail.
|
||||||
@@ -53,11 +54,15 @@ type Api struct {
|
|||||||
libreOffice libreOffice
|
libreOffice libreOffice
|
||||||
supervisor gotenberg.ProcessSupervisor
|
supervisor gotenberg.ProcessSupervisor
|
||||||
|
|
||||||
|
version string
|
||||||
|
versionOnce sync.Once
|
||||||
|
|
||||||
reqsCounter metric.Int64Counter
|
reqsCounter metric.Int64Counter
|
||||||
errsCounter metric.Int64Counter
|
errsCounter metric.Int64Counter
|
||||||
conversionDurationCounter metric.Float64Histogram
|
conversionDurationCounter metric.Float64Histogram
|
||||||
queueWaitDurationCounter metric.Float64Histogram
|
queueWaitDurationCounter metric.Float64Histogram
|
||||||
pdfOutputSizeCounter metric.Int64Histogram
|
pdfOutputSizeCounter metric.Int64Histogram
|
||||||
|
coreDumpedRetriesCounter metric.Int64Counter
|
||||||
}
|
}
|
||||||
|
|
||||||
// Options gathers available options when converting a document to PDF.
|
// Options gathers available options when converting a document to PDF.
|
||||||
@@ -370,7 +375,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
|||||||
|
|
||||||
// Process.
|
// Process.
|
||||||
a.libreOffice = newLibreOfficeProcess(a.args)
|
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.
|
// Metrics.
|
||||||
meter := gotenberg.Meter()
|
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)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,12 +491,12 @@ func (a *Api) Validate() error {
|
|||||||
|
|
||||||
_, statErr := os.Stat(a.args.binPath)
|
_, statErr := os.Stat(a.args.binPath)
|
||||||
if os.IsNotExist(statErr) {
|
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)
|
_, statErr = os.Stat(a.args.unoBinPath)
|
||||||
if os.IsNotExist(statErr) {
|
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
|
return err
|
||||||
@@ -530,19 +544,46 @@ func (a *Api) Stop(ctx context.Context) error {
|
|||||||
|
|
||||||
// Debug returns additional debug data.
|
// Debug returns additional debug data.
|
||||||
func (a *Api) Debug() map[string]any {
|
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
|
// detectVersion resolves the LibreOffice version once, preferring the value
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
// 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()
|
cmd := exec.Command(a.args.binPath, "--version") //nolint:gosec
|
||||||
if err != nil {
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
debug["version"] = err.Error()
|
|
||||||
return debug
|
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 append(attrs, extra...)
|
||||||
return debug
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metrics returns the metrics.
|
// 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 {
|
func (a *Api) Pdf(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "libreoffice.Pdf",
|
ctx, span := gotenberg.Tracer().Start(ctx, "libreoffice.Pdf",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(a.args.binPath)),
|
trace.WithAttributes(a.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
start := time.Now()
|
span.SetAttributes(
|
||||||
var conversionStart time.Time
|
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 {
|
// ErrCoreDumped happens randomly (https://github.com/gotenberg/gotenberg/issues/639);
|
||||||
conversionStart = time.Now()
|
// retry the conversion, but cap the retries so a permanently failing
|
||||||
return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options)
|
// document cannot loop forever. Each attempt records its own metrics.
|
||||||
})
|
const maxCoreDumpedRetries = 10
|
||||||
|
|
||||||
// Determine status and error reason.
|
var err error
|
||||||
status := "success"
|
var reason string
|
||||||
reason := ""
|
for attempt := 0; ; attempt++ {
|
||||||
|
start := time.Now()
|
||||||
|
var conversionStart time.Time
|
||||||
|
|
||||||
if err != nil {
|
err = a.supervisor.Run(ctx, logger, func() error {
|
||||||
switch {
|
conversionStart = time.Now()
|
||||||
case errors.Is(err, context.DeadlineExceeded):
|
return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options)
|
||||||
status = "timeout"
|
})
|
||||||
reason = "timeout"
|
|
||||||
case errors.Is(err, context.Canceled):
|
// Determine status and error reason.
|
||||||
|
status := "success"
|
||||||
|
reason = ""
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
status = "error"
|
status = "error"
|
||||||
reason = "context_cancelled"
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) || errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
|
status = "timeout"
|
||||||
status = "error"
|
}
|
||||||
reason = "libreoffice_unavailable"
|
reason = libreofficeErrorType(err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
span.SetStatus(codes.Ok, "")
|
// Record metrics for this attempt.
|
||||||
return nil
|
attrs := metric.WithAttributes(attribute.String("status", status))
|
||||||
}
|
a.reqsCounter.Add(ctx, 1, attrs)
|
||||||
|
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/639.
|
if reason != "" {
|
||||||
if errors.Is(err, ErrCoreDumped) {
|
a.errsCounter.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
|
||||||
logger.DebugContext(ctx, fmt.Sprintf("got a '%s' error, retry conversion", err))
|
}
|
||||||
return a.Pdf(ctx, logger, inputPath, outputPath, options)
|
|
||||||
|
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.RecordError(err)
|
||||||
span.SetStatus(codes.Error, err.Error())
|
span.SetStatus(codes.Error, err.Error())
|
||||||
return fmt.Errorf("supervisor run task: %w", err)
|
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.
|
// Extensions returns the file extensions available for conversions.
|
||||||
// FIXME: don't care, take all on the route level?
|
// FIXME: don't care, take all on the route level?
|
||||||
func (a *Api) Extensions() []string {
|
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"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -69,11 +70,13 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
|
|||||||
|
|
||||||
// LibreOffice fetches external content (OOXML images via
|
// LibreOffice fetches external content (OOXML images via
|
||||||
// TargetMode=External, RTF INCLUDEPICTURE, ODT linked images) inside
|
// TargetMode=External, RTF INCLUDEPICTURE, ODT linked images) inside
|
||||||
// its own libcurl. Route those fetches through the in-process proxy
|
// its own libcurl. The profile config routes those fetches through the
|
||||||
// so the chromium/webhook SSRF filters apply.
|
// in-process proxy so the chromium/webhook SSRF filters apply, and
|
||||||
if err := writeSofficeProxyConfig(userProfileDirPath, proxy.Addr()); err != nil {
|
// 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())
|
_ = 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())
|
sofficeEnv := sofficeProxyEnv(os.Environ(), proxy.Addr())
|
||||||
|
|
||||||
@@ -195,7 +198,7 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
|
|||||||
select {
|
select {
|
||||||
case err = <-connChan:
|
case err = <-connChan:
|
||||||
if err != nil {
|
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")
|
logger.DebugContext(context.Background(), "LibreOffice socket available")
|
||||||
@@ -203,7 +206,7 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
case err = <-waitChan:
|
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")
|
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("ExportFormFields=%t", options.ExportFormFields))
|
||||||
args = append(args, "--export", fmt.Sprintf("AllowDuplicateFieldNames=%t", options.AllowDuplicateFieldNames))
|
args = append(args, "--export", fmt.Sprintf("AllowDuplicateFieldNames=%t", options.AllowDuplicateFieldNames))
|
||||||
args = append(args, "--export", fmt.Sprintf("ExportBookmarks=%t", options.ExportBookmarks))
|
args = append(args, "--export", fmt.Sprintf("ExportBookmarks=%t", options.ExportBookmarks))
|
||||||
|
|||||||
@@ -245,13 +245,26 @@ var hopByHopHeaders = []string{
|
|||||||
"Upgrade",
|
"Upgrade",
|
||||||
}
|
}
|
||||||
|
|
||||||
// sofficeProxyConfigTmpl is the registrymodifications.xcu fragment that
|
// sofficeProfileConfigTmpl is the registrymodifications.xcu the soffice
|
||||||
// tells soffice's UCB layer to route every HTTP and HTTPS fetch through
|
// daemon loads at startup. It does two things:
|
||||||
// proxyHost:proxyPort. The %s placeholders accept the proxy host and
|
//
|
||||||
// port respectively (host first, port second, repeated for HTTP and
|
// 1. Routes every HTTP and HTTPS fetch through proxyHost:proxyPort so
|
||||||
// HTTPS).
|
// soffice's own libcurl fetches hit the in-process SSRF proxy.
|
||||||
const sofficeProxyConfigTmpl = `<?xml version="1.0" encoding="UTF-8"?>
|
// 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">
|
<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="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="ooInetHTTPProxyName" oor:op="fuse"><value>%s</value></prop></item>
|
||||||
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetHTTPProxyPort" oor:op="fuse"><value>%s</value></prop></item>
|
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetHTTPProxyPort" oor:op="fuse"><value>%s</value></prop></item>
|
||||||
@@ -261,10 +274,11 @@ const sofficeProxyConfigTmpl = `<?xml version="1.0" encoding="UTF-8"?>
|
|||||||
</oor:items>
|
</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
|
// userProfileDirPath/user/ that points soffice's UCB layer at proxyAddr
|
||||||
// for both HTTP and HTTPS. proxyAddr must be a host:port pair.
|
// for both HTTP and HTTPS and blocks linked content from untrusted
|
||||||
func writeSofficeProxyConfig(userProfileDirPath, proxyAddr string) error {
|
// locations. proxyAddr must be a host:port pair.
|
||||||
|
func writeSofficeProfileConfig(userProfileDirPath, proxyAddr string) error {
|
||||||
host, port, err := net.SplitHostPort(proxyAddr)
|
host, port, err := net.SplitHostPort(proxyAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("split proxy address %q: %w", proxyAddr, err)
|
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)
|
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)
|
err = os.WriteFile(userDir+"/registrymodifications.xcu", []byte(body), 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("write registrymodifications.xcu: %w", err)
|
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()
|
dir := t.TempDir()
|
||||||
|
|
||||||
if err := writeSofficeProxyConfig(dir, "127.0.0.1:9876"); err != nil {
|
if err := writeSofficeProfileConfig(dir, "127.0.0.1:9876"); err != nil {
|
||||||
t.Fatalf("writeSofficeProxyConfig: %v", err)
|
t.Fatalf("writeSofficeProfileConfig: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := os.ReadFile(filepath.Join(dir, "user", "registrymodifications.xcu"))
|
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>`,
|
`ooInetHTTPProxyName`, `<value>127.0.0.1</value>`,
|
||||||
`ooInetHTTPProxyPort`, `<value>9876</value>`,
|
`ooInetHTTPProxyPort`, `<value>9876</value>`,
|
||||||
`ooInetHTTPSProxyName`, `ooInetHTTPSProxyPort`,
|
`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) {
|
if !strings.Contains(string(body), want) {
|
||||||
t.Errorf("xcu missing %q\nfull body:\n%s", want, body)
|
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) {
|
func TestWriteSofficeProfileConfig_InvalidAddr(t *testing.T) {
|
||||||
err := writeSofficeProxyConfig(t.TempDir(), "not-a-host-port")
|
err := writeSofficeProfileConfig(t.TempDir(), "not-a-host-port")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for malformed proxy address")
|
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.
|
// 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)
|
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)
|
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.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ gotenberg.Module = (*LibreOfficePdfEngine)(nil)
|
_ gotenberg.Module = (*LibreOfficePdfEngine)(nil)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
splitMode := pdfengines.FormDataPdfSplitMode(form, false)
|
splitMode := pdfengines.FormDataPdfSplitMode(form, false)
|
||||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||||
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
||||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||||
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
||||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
||||||
@@ -38,6 +38,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||||
angle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
angle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||||
|
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||||
|
|
||||||
zeroValuedSplitMode := gotenberg.SplitMode{}
|
zeroValuedSplitMode := gotenberg.SplitMode{}
|
||||||
|
|
||||||
@@ -313,13 +314,27 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
return fmt.Errorf("validate stamp: %w", err)
|
return fmt.Errorf("validate stamp: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
|
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 ||
|
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))
|
outputPaths := make([]string, len(inputPaths))
|
||||||
for i, inputPath := range 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),
|
fmt.Errorf("convert to PDF: %w", err),
|
||||||
api.NewSentinelHttpError(
|
api.NewSentinelHttpError(
|
||||||
http.StatusBadRequest,
|
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)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.opentelemetry.io/otel/codes"
|
"go.opentelemetry.io/otel/codes"
|
||||||
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
||||||
"go.opentelemetry.io/otel/trace"
|
"go.opentelemetry.io/otel/trace"
|
||||||
@@ -30,6 +32,9 @@ func init() {
|
|||||||
// [gotenberg.PdfEngine] interface.
|
// [gotenberg.PdfEngine] interface.
|
||||||
type PdfCpu struct {
|
type PdfCpu struct {
|
||||||
binPath string
|
binPath string
|
||||||
|
|
||||||
|
version string
|
||||||
|
versionOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
type pdfcpuBookmark struct {
|
type pdfcpuBookmark struct {
|
||||||
@@ -74,35 +79,60 @@ func (engine *PdfCpu) Validate() error {
|
|||||||
|
|
||||||
// Debug returns additional debug data.
|
// Debug returns additional debug data.
|
||||||
func (engine *PdfCpu) Debug() map[string]any {
|
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
|
// detectVersion resolves the pdfcpu version once, preferring the value captured
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
// at image build time so it never spawns pdfcpu at runtime. It falls back to
|
||||||
|
// running pdfcpu version for local or non-Docker builds.
|
||||||
output, err := cmd.Output()
|
func (engine *PdfCpu) detectVersion() string {
|
||||||
if err != nil {
|
engine.versionOnce.Do(func() {
|
||||||
debug["version"] = err.Error()
|
if v, ok := gotenberg.BuildVersion("pdfcpu"); ok {
|
||||||
return debug
|
engine.version = v
|
||||||
}
|
return
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
// Merge combines multiple PDFs into a single PDF.
|
||||||
func (engine *PdfCpu) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
func (engine *PdfCpu) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Merge",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Merge",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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) {
|
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",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Split",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *PdfCpu) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Flatten",
|
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Flatten",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *PdfCpu) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Convert",
|
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Convert",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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) {
|
func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.ReadMetadata",
|
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.ReadMetadata",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.WriteMetadata",
|
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.WriteMetadata",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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) {
|
func (engine *PdfCpu) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.PageCount",
|
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.PageCount",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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) {
|
func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.ReadBookmarks",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.ReadBookmarks",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.WriteBookmarks",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.WriteBookmarks",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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)
|
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
|
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
|
||||||
// without modifying the main PDF content.
|
// without modifying the main PDF content.
|
||||||
func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.EmbedFiles",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.EmbedFiles",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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.
|
// 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",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Encrypt",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
if userPassword == "" {
|
ownerPassword := opts.OwnerPassword
|
||||||
err := errors.New("user password cannot be empty")
|
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.RecordError(err)
|
||||||
span.SetStatus(codes.Error, err.Error())
|
span.SetStatus(codes.Error, err.Error())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if ownerPassword == "" {
|
// pdfcpu only supports coarse permissions: all actions or none.
|
||||||
ownerPassword = userPassword
|
perm := "all"
|
||||||
|
if opts.Permissions.Restricted() {
|
||||||
|
perm = "none"
|
||||||
}
|
}
|
||||||
|
|
||||||
args := make([]string, 0, 11)
|
args := make([]string, 0, 11)
|
||||||
args = append(args, "encrypt")
|
args = append(args, "encrypt")
|
||||||
args = append(args, "--mode", "aes")
|
args = append(args, "--mode", "aes")
|
||||||
args = append(args, "--upw", userPassword)
|
args = append(args, "--upw", opts.UserPassword)
|
||||||
args = append(args, "--opw", ownerPassword)
|
args = append(args, "--opw", ownerPassword)
|
||||||
args = append(args, "--perm", "all")
|
args = append(args, "--perm", perm)
|
||||||
args = append(args, inputPath, inputPath)
|
args = append(args, inputPath, inputPath)
|
||||||
|
|
||||||
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
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 {
|
func (engine *PdfCpu) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Watermark",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Watermark",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *PdfCpu) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Stamp",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Stamp",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
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",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Rotate",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"sync"
|
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.opentelemetry.io/otel/codes"
|
"go.opentelemetry.io/otel/codes"
|
||||||
"go.opentelemetry.io/otel/trace"
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ type multiPdfEngines struct {
|
|||||||
watermarkEngines []gotenberg.PdfEngine
|
watermarkEngines []gotenberg.PdfEngine
|
||||||
stampEngines []gotenberg.PdfEngine
|
stampEngines []gotenberg.PdfEngine
|
||||||
rotateEngines []gotenberg.PdfEngine
|
rotateEngines []gotenberg.PdfEngine
|
||||||
|
facturXEngines []gotenberg.PdfEngine
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMultiPdfEngines(
|
func newMultiPdfEngines(
|
||||||
@@ -44,7 +45,8 @@ func newMultiPdfEngines(
|
|||||||
writeBookmarksEngines,
|
writeBookmarksEngines,
|
||||||
watermarkEngines,
|
watermarkEngines,
|
||||||
stampEngines,
|
stampEngines,
|
||||||
rotateEngines []gotenberg.PdfEngine,
|
rotateEngines,
|
||||||
|
facturXEngines []gotenberg.PdfEngine,
|
||||||
) *multiPdfEngines {
|
) *multiPdfEngines {
|
||||||
return &multiPdfEngines{
|
return &multiPdfEngines{
|
||||||
mergeEngines: mergeEngines,
|
mergeEngines: mergeEngines,
|
||||||
@@ -61,586 +63,292 @@ func newMultiPdfEngines(
|
|||||||
watermarkEngines: watermarkEngines,
|
watermarkEngines: watermarkEngines,
|
||||||
stampEngines: stampEngines,
|
stampEngines: stampEngines,
|
||||||
rotateEngines: rotateEngines,
|
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
|
// Merge combines multiple PDF files into a single document using the first
|
||||||
// available engine that supports PDF merging.
|
// available engine that supports PDF merging.
|
||||||
//
|
|
||||||
//nolint:dupl
|
|
||||||
func (multi *multiPdfEngines) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
func (multi *multiPdfEngines) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallbackVoid(ctx, "pdfengines.Merge", multi.mergeEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.Merge", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||||
defer span.End()
|
return engine.Merge(ctx, logger, inputPaths, outputPath)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error { return fmt.Errorf("merge PDFs with multi PDF engines: %w", err) },
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split divides the PDF into separate pages using the first available engine
|
// Split divides the PDF into separate pages using the first available engine
|
||||||
// that supports PDF splitting.
|
// that supports PDF splitting.
|
||||||
func (multi *multiPdfEngines) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
func (multi *multiPdfEngines) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallback(ctx, "pdfengines.Split", multi.splitEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.Split", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) ([]string, error) {
|
||||||
defer span.End()
|
return engine.Split(ctx, logger, mode, inputPath, outputDirPath)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error { return fmt.Errorf("split PDF with multi PDF engines: %w", err) },
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flatten merges existing annotation appearances with page content using the
|
// Flatten merges existing annotation appearances with page content using the
|
||||||
// first available engine that supports flattening.
|
// first available engine that supports flattening.
|
||||||
func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
|
func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallbackVoid(ctx, "pdfengines.Flatten", multi.flattenEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.Flatten", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||||
defer span.End()
|
return engine.Flatten(ctx, logger, inputPath)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error { return fmt.Errorf("flatten PDF with multi PDF engines: %w", err) },
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert transforms the given PDF to a specific PDF format using the first
|
// Convert transforms the given PDF to a specific PDF format using the first
|
||||||
// available engine that supports PDF conversion.
|
// available engine that supports PDF conversion.
|
||||||
func (multi *multiPdfEngines) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
func (multi *multiPdfEngines) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallbackVoid(ctx, "pdfengines.Convert", multi.convertEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.Convert", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||||
defer span.End()
|
return engine.Convert(ctx, logger, formats, inputPath, outputPath)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error {
|
||||||
errChan := make(chan error, 1)
|
return fmt.Errorf("convert PDF to '%+v' with multi PDF engines: %w", formats, err)
|
||||||
|
},
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadMetadata extracts metadata from a PDF file using the first available
|
// ReadMetadata extracts metadata from a PDF file using the first available
|
||||||
// engine that supports metadata reading.
|
// engine that supports metadata reading.
|
||||||
//
|
|
||||||
//nolint:dupl
|
|
||||||
func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallback(ctx, "pdfengines.ReadMetadata", multi.readMetadataEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.ReadMetadata", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) (map[string]any, error) {
|
||||||
defer span.End()
|
return engine.ReadMetadata(ctx, logger, inputPath)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error { return fmt.Errorf("read PDF metadata with multi PDF engines: %w", err) },
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteMetadata embeds metadata into a PDF file using the first available
|
// WriteMetadata embeds metadata into a PDF file using the first available
|
||||||
// engine that supports metadata writing.
|
// engine that supports metadata writing.
|
||||||
func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
|
func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallbackVoid(ctx, "pdfengines.WriteMetadata", multi.writeMetadataEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.WriteMetadata", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||||
defer span.End()
|
return engine.WriteMetadata(ctx, logger, metadata, inputPath)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error { return fmt.Errorf("write PDF metadata with multi PDF engines: %w", err) },
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PageCount returns the number of pages in a PDF file using the first available
|
// PageCount returns the number of pages in a PDF file using the first available
|
||||||
// engine that supports metadata reading.
|
// engine that supports metadata reading.
|
||||||
func (multi *multiPdfEngines) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
|
func (multi *multiPdfEngines) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallback(ctx, "pdfengines.PageCount", multi.readMetadataEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.PageCount", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) (int, error) {
|
||||||
defer span.End()
|
return engine.PageCount(ctx, logger, inputPath)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error { return fmt.Errorf("page count with multi PDF engines: %w", err) },
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadBookmarks reads bookmarks from a PDF file using the first available
|
// ReadBookmarks reads bookmarks from a PDF file using the first available
|
||||||
// engine that supports bookmarks reading.
|
// engine that supports bookmarks reading.
|
||||||
//
|
|
||||||
//nolint:dupl
|
|
||||||
func (multi *multiPdfEngines) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
|
func (multi *multiPdfEngines) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallback(ctx, "pdfengines.ReadBookmarks", multi.readBookmarksEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.ReadBookmarks", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) ([]gotenberg.Bookmark, error) {
|
||||||
defer span.End()
|
return engine.ReadBookmarks(ctx, logger, inputPath)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error { return fmt.Errorf("read PDF bookmarks with multi PDF engines: %w", err) },
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteBookmarks adds a document outline (bookmarks) to a PDF file using the
|
// WriteBookmarks adds a document outline (bookmarks) to a PDF file using the
|
||||||
// first available engine that supports bookmarks writing.
|
// first available engine that supports bookmarks writing.
|
||||||
func (multi *multiPdfEngines) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
|
func (multi *multiPdfEngines) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallbackVoid(ctx, "pdfengines.WriteBookmarks", multi.writeBookmarksEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.WriteBookmarks", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||||
defer span.End()
|
return engine.WriteBookmarks(ctx, logger, inputPath, bookmarks)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error { return fmt.Errorf("write PDF bookmarks with multi PDF engines: %w", err) },
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt adds password protection to a PDF file using the first available
|
// Encrypt adds password protection to a PDF file using the first available
|
||||||
// engine that supports password protection.
|
// engine that supports password protection.
|
||||||
func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
|
func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallbackVoid(ctx, "pdfengines.Encrypt", multi.passwordEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.Encrypt", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||||
defer span.End()
|
return engine.Encrypt(ctx, logger, inputPath, opts)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error { return fmt.Errorf("encrypt PDF using multi PDF engines: %w", err) },
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmbedFiles embeds files into a PDF using the first available
|
// EmbedFiles embeds files into a PDF using the first available engine that
|
||||||
// engine that supports file embedding.
|
// supports file embedding.
|
||||||
//
|
|
||||||
//nolint:dupl
|
|
||||||
func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallbackVoid(ctx, "pdfengines.EmbedFiles", multi.embedEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.EmbedFiles", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||||
defer span.End()
|
return engine.EmbedFiles(ctx, logger, filePaths, inputPath)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error { return fmt.Errorf("embed files into PDF using multi PDF engines: %w", err) },
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watermark applies a watermark (behind page content) to a PDF file using the
|
// Watermark applies a watermark (behind page content) to a PDF file using the
|
||||||
// first available engine that supports watermarking.
|
// first available engine that supports watermarking.
|
||||||
//
|
|
||||||
//nolint:dupl
|
|
||||||
func (multi *multiPdfEngines) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
func (multi *multiPdfEngines) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallbackVoid(ctx, "pdfengines.Watermark", multi.watermarkEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.Watermark", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||||
defer span.End()
|
return engine.Watermark(ctx, logger, inputPath, stamp)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error { return fmt.Errorf("watermark PDF with multi PDF engines: %w", err) },
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stamp applies a stamp (on top of page content) to a PDF file using the
|
// Stamp applies a stamp (on top of page content) to a PDF file using the first
|
||||||
// first available engine that supports stamping.
|
// available engine that supports stamping.
|
||||||
//
|
|
||||||
//nolint:dupl
|
|
||||||
func (multi *multiPdfEngines) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
func (multi *multiPdfEngines) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallbackVoid(ctx, "pdfengines.Stamp", multi.stampEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.Stamp", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||||
defer span.End()
|
return engine.Stamp(ctx, logger, inputPath, stamp)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error { return fmt.Errorf("stamp PDF with multi PDF engines: %w", err) },
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rotate rotates pages of a PDF file using the first available engine that
|
// Rotate rotates pages of a PDF file using the first available engine that
|
||||||
// supports rotation.
|
// supports rotation.
|
||||||
func (multi *multiPdfEngines) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
|
func (multi *multiPdfEngines) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallbackVoid(ctx, "pdfengines.Rotate", multi.rotateEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.Rotate", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||||
defer span.End()
|
return engine.Rotate(ctx, logger, inputPath, angle, pages)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error { return fmt.Errorf("rotate PDF with multi PDF engines: %w", err) },
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmbedFilesMetadata sets metadata on embedded files using the first available
|
// EmbedFilesMetadata sets metadata on embedded files using the first available
|
||||||
// engine that supports it.
|
// 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 {
|
func (multi *multiPdfEngines) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallbackVoid(ctx, "pdfengines.EmbedFilesMetadata", multi.embedMetadataEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.EmbedFilesMetadata", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||||
defer span.End()
|
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
|
// InjectFacturXXMP injects Factur-X/ZUGFeRD XMP metadata using the first
|
||||||
errChan := make(chan error, 1)
|
// 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 {
|
// ReadPdfAConformance reads the PDF/A part and conformance using the first
|
||||||
go func(engine gotenberg.PdfEngine) {
|
// available engine that supports it.
|
||||||
errChan <- engine.EmbedFilesMetadata(ctx, logger, metadata, inputPath)
|
func (multi *multiPdfEngines) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
|
||||||
}(engine)
|
type pdfaConf struct {
|
||||||
|
part string
|
||||||
select {
|
conformance string
|
||||||
case setErr := <-errChan:
|
|
||||||
if setErr != nil {
|
|
||||||
err = errors.Join(err, setErr)
|
|
||||||
} else {
|
|
||||||
span.SetStatus(codes.Ok, "")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
result, err := runWithFallback(ctx, "pdfengines.ReadPdfAConformance", multi.facturXEngines,
|
||||||
err = fmt.Errorf("set embeds metadata using multi PDF engines: %w", err)
|
func(ctx context.Context, engine gotenberg.PdfEngine) (pdfaConf, error) {
|
||||||
span.RecordError(err)
|
part, conformance, err := engine.ReadPdfAConformance(ctx, logger, inputPath)
|
||||||
span.SetStatus(codes.Error, err.Error())
|
return pdfaConf{part: part, conformance: conformance}, err
|
||||||
|
},
|
||||||
return 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.
|
// Interface guards.
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ type PdfEngines struct {
|
|||||||
watermarkNames []string
|
watermarkNames []string
|
||||||
stampNames []string
|
stampNames []string
|
||||||
rotateNames []string
|
rotateNames []string
|
||||||
|
facturXNames []string
|
||||||
engines []gotenberg.PdfEngine
|
engines []gotenberg.PdfEngine
|
||||||
disableRoutes bool
|
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-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-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-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")
|
fs.Bool("pdfengines-disable-routes", false, "Disable the routes")
|
||||||
|
|
||||||
// Deprecated flags.
|
// Deprecated flags.
|
||||||
@@ -99,6 +101,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
|
|||||||
watermarkNames := flags.MustStringSlice("pdfengines-watermark-engines")
|
watermarkNames := flags.MustStringSlice("pdfengines-watermark-engines")
|
||||||
stampNames := flags.MustStringSlice("pdfengines-stamp-engines")
|
stampNames := flags.MustStringSlice("pdfengines-stamp-engines")
|
||||||
rotateNames := flags.MustStringSlice("pdfengines-rotate-engines")
|
rotateNames := flags.MustStringSlice("pdfengines-rotate-engines")
|
||||||
|
facturXNames := flags.MustStringSlice("pdfengines-factur-x-engines")
|
||||||
mod.disableRoutes = flags.MustBool("pdfengines-disable-routes")
|
mod.disableRoutes = flags.MustBool("pdfengines-disable-routes")
|
||||||
|
|
||||||
engines, err := ctx.Modules(new(gotenberg.PdfEngine))
|
engines, err := ctx.Modules(new(gotenberg.PdfEngine))
|
||||||
@@ -195,6 +198,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
|
|||||||
mod.rotateNames = rotateNames
|
mod.rotateNames = rotateNames
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod.facturXNames = defaultNames
|
||||||
|
if len(facturXNames) > 0 {
|
||||||
|
mod.facturXNames = facturXNames
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +211,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
|
|||||||
// actually exist.
|
// actually exist.
|
||||||
func (mod *PdfEngines) Validate() error {
|
func (mod *PdfEngines) Validate() error {
|
||||||
if len(mod.engines) == 0 {
|
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))
|
availableEngines := make([]string, len(mod.engines))
|
||||||
@@ -250,6 +258,7 @@ func (mod *PdfEngines) Validate() error {
|
|||||||
findNonExistingEngines(mod.watermarkNames)
|
findNonExistingEngines(mod.watermarkNames)
|
||||||
findNonExistingEngines(mod.stampNames)
|
findNonExistingEngines(mod.stampNames)
|
||||||
findNonExistingEngines(mod.rotateNames)
|
findNonExistingEngines(mod.rotateNames)
|
||||||
|
findNonExistingEngines(mod.facturXNames)
|
||||||
|
|
||||||
if len(nonExistingEngines) == 0 {
|
if len(nonExistingEngines) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -276,6 +285,7 @@ func (mod *PdfEngines) SystemMessages() []string {
|
|||||||
fmt.Sprintf("watermark engines - %s", strings.Join(mod.watermarkNames, " ")),
|
fmt.Sprintf("watermark engines - %s", strings.Join(mod.watermarkNames, " ")),
|
||||||
fmt.Sprintf("stamp engines - %s", strings.Join(mod.stampNames, " ")),
|
fmt.Sprintf("stamp engines - %s", strings.Join(mod.stampNames, " ")),
|
||||||
fmt.Sprintf("rotate engines - %s", strings.Join(mod.rotateNames, " ")),
|
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.watermarkNames),
|
||||||
engines(mod.stampNames),
|
engines(mod.stampNames),
|
||||||
engines(mod.rotateNames),
|
engines(mod.rotateNames),
|
||||||
|
engines(mod.facturXNames),
|
||||||
), nil
|
), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +351,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
|
|||||||
watermarkRoute(engine),
|
watermarkRoute(engine),
|
||||||
stampRoute(engine),
|
stampRoute(engine),
|
||||||
rotateRoute(engine),
|
rotateRoute(engine),
|
||||||
|
facturXRoute(engine),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -467,21 +467,244 @@ func EmbedFilesMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metada
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormDataPdfEncrypt extracts encryption parameters from form data.
|
// FormDataPdfFacturX extracts the Factur-X parameters and the invoice XML path
|
||||||
func FormDataPdfEncrypt(form *api.FormData) (userPassword, ownerPassword string) {
|
// from form data. Factur-X is requested when both facturxConformanceLevel and
|
||||||
form.String("userPassword", &userPassword, "")
|
// facturxXml are provided. The embedded XML always takes the canonical
|
||||||
form.String("ownerPassword", &ownerPassword, "")
|
// [gotenberg.FacturXDocumentFileName] name.
|
||||||
return userPassword, ownerPassword
|
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.
|
// isPdfA3 reports whether the format is a PDF/A-3 variant, the only family that
|
||||||
func EncryptPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, userPassword, ownerPassword string, inputPaths []string) error {
|
// allows the embedded files Factur-X requires.
|
||||||
if userPassword == "" {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, inputPath := range inputPaths {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("encrypt PDF '%s': %w", inputPath, err)
|
return fmt.Errorf("encrypt PDF '%s': %w", inputPath, err)
|
||||||
}
|
}
|
||||||
@@ -699,7 +922,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
pdfFormats := FormDataPdfFormats(form)
|
pdfFormats := FormDataPdfFormats(form)
|
||||||
metadata := FormDataPdfMetadata(form, false)
|
metadata := FormDataPdfMetadata(form, false)
|
||||||
bookmarks := FormDataPdfBookmarks(form, false)
|
bookmarks := FormDataPdfBookmarks(form, false)
|
||||||
userPassword, ownerPassword := FormDataPdfEncrypt(form)
|
encrypt := FormDataPdfEncrypt(form)
|
||||||
embedPaths := FormDataPdfEmbeds(form)
|
embedPaths := FormDataPdfEmbeds(form)
|
||||||
watermark := FormDataPdfWatermark(form, false)
|
watermark := FormDataPdfWatermark(form, false)
|
||||||
watermarkFile := FormDataPdfWatermarkFile(form)
|
watermarkFile := FormDataPdfWatermarkFile(form)
|
||||||
@@ -707,6 +930,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
stampFile := FormDataPdfStampFile(form)
|
stampFile := FormDataPdfStampFile(form)
|
||||||
angle, rotatePages := FormDataPdfRotate(form, false)
|
angle, rotatePages := FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
||||||
|
facturX, facturxXmlPath := FormDataPdfFacturX(form)
|
||||||
|
|
||||||
var inputPaths []string
|
var inputPaths []string
|
||||||
var flatten bool
|
var flatten bool
|
||||||
@@ -729,7 +953,17 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("validate stamp: %w", err)
|
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 {
|
if err != nil {
|
||||||
return err
|
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)
|
outputPaths, err = ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert PDF: %w", err)
|
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)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||||
}
|
}
|
||||||
@@ -858,7 +1099,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
mode := FormDataPdfSplitMode(form, true)
|
mode := FormDataPdfSplitMode(form, true)
|
||||||
pdfFormats := FormDataPdfFormats(form)
|
pdfFormats := FormDataPdfFormats(form)
|
||||||
metadata := FormDataPdfMetadata(form, false)
|
metadata := FormDataPdfMetadata(form, false)
|
||||||
userPassword, ownerPassword := FormDataPdfEncrypt(form)
|
encrypt := FormDataPdfEncrypt(form)
|
||||||
embedPaths := FormDataPdfEmbeds(form)
|
embedPaths := FormDataPdfEmbeds(form)
|
||||||
watermark := FormDataPdfWatermark(form, false)
|
watermark := FormDataPdfWatermark(form, false)
|
||||||
watermarkFile := FormDataPdfWatermarkFile(form)
|
watermarkFile := FormDataPdfWatermarkFile(form)
|
||||||
@@ -866,6 +1107,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
stampFile := FormDataPdfStampFile(form)
|
stampFile := FormDataPdfStampFile(form)
|
||||||
angle, rotatePages := FormDataPdfRotate(form, false)
|
angle, rotatePages := FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
||||||
|
facturX, facturxXmlPath := FormDataPdfFacturX(form)
|
||||||
|
|
||||||
var inputPaths []string
|
var inputPaths []string
|
||||||
var flatten bool
|
var flatten bool
|
||||||
@@ -886,7 +1128,17 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("validate stamp: %w", err)
|
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 {
|
if err != nil {
|
||||||
return err
|
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)
|
convertOutputPaths, err := ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert PDFs: %w", err)
|
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)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1224,20 +1483,26 @@ func encryptRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
ctx := c.Get("context").(*api.Context)
|
ctx := c.Get("context").(*api.Context)
|
||||||
|
|
||||||
form := ctx.FormData()
|
form := ctx.FormData()
|
||||||
|
encrypt := FormDataPdfEncrypt(form)
|
||||||
|
|
||||||
var inputPaths []string
|
var inputPaths []string
|
||||||
var userPassword string
|
|
||||||
var ownerPassword string
|
|
||||||
err := form.
|
err := form.
|
||||||
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
||||||
MandatoryString("userPassword", &userPassword).
|
|
||||||
String("ownerPassword", &ownerPassword, "").
|
|
||||||
Validate()
|
Validate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate form data: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1265,6 +1530,7 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
form := ctx.FormData()
|
form := ctx.FormData()
|
||||||
embedPaths := FormDataPdfEmbeds(form)
|
embedPaths := FormDataPdfEmbeds(form)
|
||||||
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
||||||
|
facturX, facturxXmlPath := FormDataPdfFacturX(form)
|
||||||
|
|
||||||
var inputPaths []string
|
var inputPaths []string
|
||||||
err := form.
|
err := form.
|
||||||
@@ -1273,17 +1539,36 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate form data: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("embed files into PDFs: %w", err)
|
return fmt.Errorf("embed files into PDFs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = EmbedFilesMetadataStub(ctx, engine, embedsMetadata, inputPaths)
|
err = EmbedFilesMetadataStub(ctx, engine, embedsMetadata, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("set embeds metadata: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("add output paths: %w", err)
|
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"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.opentelemetry.io/otel/codes"
|
"go.opentelemetry.io/otel/codes"
|
||||||
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
||||||
"go.opentelemetry.io/otel/trace"
|
"go.opentelemetry.io/otel/trace"
|
||||||
@@ -26,6 +28,9 @@ func init() {
|
|||||||
// interface.
|
// interface.
|
||||||
type PdfTk struct {
|
type PdfTk struct {
|
||||||
binPath string
|
binPath string
|
||||||
|
|
||||||
|
version string
|
||||||
|
versionOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
// Descriptor returns a [PdfTk]'s module descriptor.
|
// Descriptor returns a [PdfTk]'s module descriptor.
|
||||||
@@ -60,32 +65,58 @@ func (engine *PdfTk) Validate() error {
|
|||||||
|
|
||||||
// Debug returns additional debug data.
|
// Debug returns additional debug data.
|
||||||
func (engine *PdfTk) Debug() map[string]any {
|
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
|
// detectVersion resolves the PDFtk version once, preferring the value captured
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
// 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()
|
cmd := exec.Command(engine.binPath, "--version") //nolint:gosec
|
||||||
if err != nil {
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
debug["version"] = err.Error()
|
|
||||||
return debug
|
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)
|
return append(attrs, extra...)
|
||||||
if len(lines) > 0 {
|
|
||||||
debug["version"] = string(lines[0])
|
|
||||||
} else {
|
|
||||||
debug["version"] = "Unable to determine PDFtk version"
|
|
||||||
}
|
|
||||||
|
|
||||||
return debug
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split splits a given PDF file.
|
// Split splits a given PDF file.
|
||||||
func (engine *PdfTk) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
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",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Split",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *PdfTk) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Merge",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Merge",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *PdfTk) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.Flatten",
|
_, span := gotenberg.Tracer().Start(ctx, "pdftk.Flatten",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *PdfTk) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.Convert",
|
_, span := gotenberg.Tracer().Start(ctx, "pdftk.Convert",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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) {
|
func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.ReadMetadata",
|
_, span := gotenberg.Tracer().Start(ctx, "pdftk.ReadMetadata",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.WriteMetadata",
|
_, span := gotenberg.Tracer().Start(ctx, "pdftk.WriteMetadata",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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) {
|
func (engine *PdfTk) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.PageCount",
|
_, span := gotenberg.Tracer().Start(ctx, "pdftk.PageCount",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *PdfTk) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.WriteBookmarks",
|
_, span := gotenberg.Tracer().Start(ctx, "pdftk.WriteBookmarks",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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) {
|
func (engine *PdfTk) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.ReadBookmarks",
|
_, span := gotenberg.Tracer().Start(ctx, "pdftk.ReadBookmarks",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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.
|
// 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",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Encrypt",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
if userPassword == "" {
|
if opts.UserPassword == "" || opts.Permissions.Restricted() {
|
||||||
err := errors.New("user password cannot be empty")
|
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.RecordError(err)
|
||||||
span.SetStatus(codes.Error, err.Error())
|
span.SetStatus(codes.Error, err.Error())
|
||||||
return err
|
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")
|
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.RecordError(err)
|
||||||
span.SetStatus(codes.Error, err.Error())
|
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, inputPath)
|
||||||
args = append(args, "output", tmpPath)
|
args = append(args, "output", tmpPath)
|
||||||
args = append(args, "encrypt_128bit")
|
args = append(args, "encrypt_128bit")
|
||||||
args = append(args, "user_pw", userPassword)
|
args = append(args, "user_pw", opts.UserPassword)
|
||||||
args = append(args, "owner_pw", ownerPassword)
|
args = append(args, "owner_pw", opts.OwnerPassword)
|
||||||
|
|
||||||
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
||||||
if err != nil {
|
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 {
|
func (engine *PdfTk) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.EmbedFiles",
|
_, span := gotenberg.Tracer().Start(ctx, "pdftk.EmbedFiles",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *PdfTk) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Watermark",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Watermark",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *PdfTk) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Stamp",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Stamp",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
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",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Rotate",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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)
|
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.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ gotenberg.Module = (*PdfTk)(nil)
|
_ 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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.opentelemetry.io/otel/codes"
|
"go.opentelemetry.io/otel/codes"
|
||||||
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
||||||
"go.opentelemetry.io/otel/trace"
|
"go.opentelemetry.io/otel/trace"
|
||||||
@@ -29,6 +34,9 @@ func init() {
|
|||||||
type QPdf struct {
|
type QPdf struct {
|
||||||
binPath string
|
binPath string
|
||||||
globalArgs []string
|
globalArgs []string
|
||||||
|
|
||||||
|
version string
|
||||||
|
versionOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
// Descriptor returns a [QPdf]'s module descriptor.
|
// 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 {
|
func (engine *QPdf) Provision(ctx *gotenberg.Context) error {
|
||||||
binPath, ok := os.LookupEnv("QPDF_BIN_PATH")
|
binPath, ok := os.LookupEnv("QPDF_BIN_PATH")
|
||||||
if !ok {
|
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
|
engine.binPath = binPath
|
||||||
@@ -65,32 +73,58 @@ func (engine *QPdf) Validate() error {
|
|||||||
|
|
||||||
// Debug returns additional debug data.
|
// Debug returns additional debug data.
|
||||||
func (engine *QPdf) Debug() map[string]any {
|
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
|
// detectVersion resolves the qpdf version once, preferring the value captured
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
// 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()
|
cmd := exec.Command(engine.binPath, "--version") //nolint:gosec
|
||||||
if err != nil {
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
debug["version"] = err.Error()
|
|
||||||
return debug
|
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)
|
return append(attrs, extra...)
|
||||||
if len(lines) > 0 {
|
|
||||||
debug["version"] = string(lines[0])
|
|
||||||
} else {
|
|
||||||
debug["version"] = "Unable to determine QPDF version"
|
|
||||||
}
|
|
||||||
|
|
||||||
return debug
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split splits a given PDF file.
|
// Split splits a given PDF file.
|
||||||
func (engine *QPdf) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
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",
|
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Split",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *QPdf) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Merge",
|
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Merge",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *QPdf) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Flatten",
|
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Flatten",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *QPdf) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Convert",
|
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Convert",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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) {
|
func (engine *QPdf) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadMetadata",
|
_, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadMetadata",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *QPdf) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.WriteMetadata",
|
_, span := gotenberg.Tracer().Start(ctx, "qpdf.WriteMetadata",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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) {
|
func (engine *QPdf) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.PageCount",
|
_, span := gotenberg.Tracer().Start(ctx, "qpdf.PageCount",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *QPdf) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.WriteBookmarks",
|
_, span := gotenberg.Tracer().Start(ctx, "qpdf.WriteBookmarks",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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) {
|
func (engine *QPdf) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadBookmarks",
|
_, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadBookmarks",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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.
|
// 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",
|
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Encrypt",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
if userPassword == "" {
|
ownerPassword := opts.OwnerPassword
|
||||||
err := errors.New("user password cannot be empty")
|
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.RecordError(err)
|
||||||
span.SetStatus(codes.Error, err.Error())
|
span.SetStatus(codes.Error, err.Error())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if ownerPassword == "" {
|
args := make([]string, 0, 14+len(engine.globalArgs))
|
||||||
ownerPassword = userPassword
|
|
||||||
}
|
|
||||||
|
|
||||||
args := make([]string, 0, 7+len(engine.globalArgs))
|
|
||||||
args = append(args, inputPath)
|
args = append(args, inputPath)
|
||||||
args = append(args, engine.globalArgs...)
|
args = append(args, engine.globalArgs...)
|
||||||
args = append(args, "--replace-input")
|
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...)
|
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
||||||
if err != nil {
|
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 {
|
func (engine *QPdf) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.EmbedFiles",
|
_, span := gotenberg.Tracer().Start(ctx, "qpdf.EmbedFiles",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
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",
|
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.EmbedFilesMetadata",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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
|
// writeAndApplyUpdate marshals the update objects as QPDF JSON v2, writes
|
||||||
// them to a temp file, and applies the update via --update-from-json.
|
// them to a temp file, and applies the update via --update-from-json. extraArgs
|
||||||
func (engine *QPdf) writeAndApplyUpdate(ctx context.Context, logger *slog.Logger, inputPath string, updateObjects map[string]any) error {
|
// 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{
|
updateJSON := map[string]any{
|
||||||
"qpdf": []any{
|
"qpdf": []any{
|
||||||
map[string]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)
|
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, inputPath)
|
||||||
updateArgs = append(updateArgs, engine.globalArgs...)
|
updateArgs = append(updateArgs, engine.globalArgs...)
|
||||||
|
updateArgs = append(updateArgs, extraArgs...)
|
||||||
updateArgs = append(updateArgs, "--newline-before-endstream")
|
updateArgs = append(updateArgs, "--newline-before-endstream")
|
||||||
updateArgs = append(updateArgs, "--update-from-json="+tmpFile.Name())
|
updateArgs = append(updateArgs, "--update-from-json="+tmpFile.Name())
|
||||||
updateArgs = append(updateArgs, "--replace-input")
|
updateArgs = append(updateArgs, "--replace-input")
|
||||||
@@ -635,11 +706,396 @@ func stripQpdfStringPrefix(s string) string {
|
|||||||
return s
|
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.
|
// Watermark is not available in this implementation.
|
||||||
func (engine *QPdf) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
func (engine *QPdf) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Watermark",
|
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Watermark",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *QPdf) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Stamp",
|
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Stamp",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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 {
|
func (engine *QPdf) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Rotate",
|
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Rotate",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
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)
|
return c.NoContent(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
if deadline, ok := ctx.Deadline(); ok {
|
cancel = detachAsyncContext(ctx, cancel)
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
|
|
||||||
// As a webhook URL has been given, we handle the request in a
|
// As a webhook URL has been given, we handle the request in a
|
||||||
// goroutine and return immediately.
|
// 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 have <N> page(s)`
|
||||||
- `the "<name>" PDF (should|should NOT) be set to landscape orientation`
|
- `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 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 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 flatten`
|
||||||
- `the (response|webhook request) PDF(s) (should|should NOT) be encrypted`
|
- `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 (response|webhook request) PDF(s) (should|should NOT) have the "<filename>" file embedded`
|
||||||
- `the "<name>" PDF should have <N> image(s)`
|
- `the "<name>" PDF should have <N> image(s)`
|
||||||
- `the Gotenberg container (should|should NOT) log the following entries:` (table of log substrings)
|
- `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 header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
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):
|
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 |
|
| 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 header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
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):
|
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 |
|
| 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 header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
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):
|
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 |
|
| 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 header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
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):
|
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 |
|
| 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 header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
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
|
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):
|
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 header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
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
|
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):
|
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
|
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)
|
Scenario: POST /forms/libreoffice/convert (Many Documents)
|
||||||
Given I have a default Gotenberg container
|
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):
|
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 header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
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):
|
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 |
|
| 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 header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
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):
|
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 |
|
| 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_1.xml" file embedded
|
||||||
Then the response PDF(s) should have the "embed_2.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.
|
# FIXME: once decrypt is done, add encrypt and check after the content of the PDF.
|
||||||
@convert
|
@convert
|
||||||
@metadata
|
@metadata
|
||||||
@@ -851,3 +939,28 @@ Feature: /forms/libreoffice/convert
|
|||||||
Then the response header "Content-Type" should be "application/pdf"
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
Then there should be 1 PDF(s) in the response
|
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 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 header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
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):
|
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 |
|
| 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 status code should be 400
|
||||||
Then the response body should match string:
|
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)
|
Scenario: POST /forms/pdfengines/encrypt (Routes Disabled)
|
||||||
Given I have a Gotenberg container with the following environment variable(s):
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
| PDFENGINES_DISABLE_ROUTES | true |
|
| 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 header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
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):
|
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 |
|
| 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 header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should contain string:
|
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)
|
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 header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
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):
|
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 |
|
| 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 header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
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):
|
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 |
|
| 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 status code should be 400
|
||||||
Then the response body should match string:
|
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
|
@download-from
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ Feature: /forms/pdfengines/watermark
|
|||||||
Then the response status code should be 400
|
Then the response status code should be 400
|
||||||
Then the response body should match string:
|
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
|
@download-from
|
||||||
|
|||||||
@@ -126,3 +126,15 @@ Feature: Webhook
|
|||||||
"timestamp": "ignore"
|
"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
|
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 {
|
func (s *scenario) theCookieValueShouldBe(kind, name, expected string) error {
|
||||||
var cookies []*http.Cookie
|
var cookies []*http.Cookie
|
||||||
switch {
|
switch {
|
||||||
@@ -1052,14 +1077,17 @@ func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context,
|
|||||||
return nil
|
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
|
var path string
|
||||||
if !strings.HasPrefix(name, "*_") {
|
if !strings.HasPrefix(name, "*_") {
|
||||||
path = fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
|
path = fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
|
||||||
|
|
||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return fmt.Errorf("PDF %q does not exist", path)
|
return "", fmt.Errorf("PDF %q does not exist", path)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
substr := strings.ReplaceAll(name, "*_", "")
|
substr := strings.ReplaceAll(name, "*_", "")
|
||||||
@@ -1074,7 +1102,7 @@ func (s *scenario) thePdfShouldHaveTheFollowingContentAtPage(ctx context.Context
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != 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)
|
output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
|
||||||
if err != nil {
|
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"
|
invert := kind == "should NOT"
|
||||||
@@ -1106,6 +1143,30 @@ func (s *scenario) thePdfShouldHaveTheFollowingContentAtPage(ctx context.Context
|
|||||||
return nil
|
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 {
|
func (s *scenario) thePdfsShouldBeFlatten(ctx context.Context, kind, should string) error {
|
||||||
dirPath := s.teststoreDir
|
dirPath := s.teststoreDir
|
||||||
|
|
||||||
@@ -1156,6 +1217,67 @@ func (s *scenario) thePdfsShouldBeFlatten(ctx context.Context, kind, should stri
|
|||||||
return nil
|
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 {
|
func (s *scenario) thePdfsShouldBeEncrypted(ctx context.Context, kind string, should string) error {
|
||||||
dirPath := s.teststoreDir
|
dirPath := s.teststoreDir
|
||||||
|
|
||||||
@@ -1324,6 +1446,57 @@ func (s *scenario) thePdfsShouldHaveEmbeddedFileWithRelationship(ctx context.Con
|
|||||||
return nil
|
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) {
|
func InitializeScenario(ctx *godog.ScenarioContext) {
|
||||||
s := &scenario{}
|
s := &scenario{}
|
||||||
ctx.Before(func(ctx context.Context, sc *godog.Scenario) (context.Context, error) {
|
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 response status codes should be (\d+)$`, s.allConcurrentResponseStatusCodesShouldBe)
|
||||||
ctx.Then(`^all concurrent responses should have (\d+) PDF\(s\)$`, s.allConcurrentResponsesShouldHavePdfs)
|
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 (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|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 match string:$`, s.theBodyShouldMatchString)
|
||||||
ctx.Then(`^the (response|webhook request) body should contain string:$`, s.theBodyShouldContainString)
|
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 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 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) 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|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 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 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) 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 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.Then(`^the "([^"]*)" PDF should have (\d+) image\(s\)$`, s.thePdfShouldHaveImages)
|
||||||
ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) {
|
ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) {
|
||||||
if s.gotenbergContainer != nil {
|
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