mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 00:22:14 +01:00
Compare commits
66 Commits
v8.33.0
...
5b21dbf1a9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b21dbf1a9 | ||
|
|
0a62359691 | ||
|
|
7418b5cab3 | ||
|
|
337a5cca64 | ||
|
|
387fce2cf6 | ||
|
|
c89991cc0d | ||
|
|
0c8d681c35 | ||
|
|
3d300c2a09 | ||
|
|
8aa5a4f0e7 | ||
|
|
a92a7fedba | ||
|
|
d0e3991d16 | ||
|
|
d67ef724f9 | ||
|
|
68a0a9e5c1 | ||
|
|
b5f77c3b73 | ||
|
|
be5dcf943d | ||
|
|
9ca1e302bd | ||
|
|
3617bc6587 | ||
|
|
874adf076c | ||
|
|
9d83255ecd | ||
|
|
3b43bfbf33 | ||
|
|
7614964109 | ||
|
|
7b054da4e7 | ||
|
|
98fc403478 | ||
|
|
808a96f3d0 | ||
|
|
d4c20c6b39 | ||
|
|
2050b4ae6b | ||
|
|
c0ed2dee3c | ||
|
|
f8905bac8c | ||
|
|
3b1e4cbac4 | ||
|
|
287ee5be72 | ||
|
|
9ab39b6fca | ||
|
|
5558e43821 | ||
|
|
40666529f9 | ||
|
|
0f5e1b794a | ||
|
|
3ab8c5920b | ||
|
|
54853e2cbd | ||
|
|
5c6a5c64b5 | ||
|
|
60482a5cdf | ||
|
|
a6faa892a8 | ||
|
|
4ebd977d97 | ||
|
|
a82dd9f031 | ||
|
|
8668a1d710 | ||
|
|
e7c8a6a50c | ||
|
|
11ab93aef6 | ||
|
|
1498473495 | ||
|
|
c24883b8a1 | ||
|
|
f6069ef84f | ||
|
|
7465166de7 | ||
|
|
de572fe6b5 | ||
|
|
2b20399a80 | ||
|
|
7c630ecc6e | ||
|
|
bbebad1175 | ||
|
|
387728f09a | ||
|
|
65e8bf20d4 | ||
|
|
e1c28d8450 | ||
|
|
c63dd5ce1e | ||
|
|
7a439632da | ||
|
|
525102b991 | ||
|
|
8e1ab0110f | ||
|
|
a5efccd4cc | ||
|
|
0b0e817ca5 | ||
|
|
f5b26c0b2c | ||
|
|
8003109012 | ||
|
|
a0ec46a764 | ||
|
|
b32545e589 | ||
|
|
cb461bb1fe |
@@ -50,9 +50,19 @@ body:multipart-form {
|
||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
~allowPrinting: false
|
||||
~allowCopying: false
|
||||
~allowModifying: false
|
||||
~allowAnnotating: false
|
||||
~allowFillingForms: false
|
||||
~allowAssembling: false
|
||||
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||
~embeds: @file(../test/integration/testdata/embed_2.xml)
|
||||
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
||||
~facturxXml: @file(../test/integration/testdata/embed_1.xml)
|
||||
~facturxConformanceLevel: EN 16931
|
||||
~facturxDocumentType: INVOICE
|
||||
~facturxVersion: 1.0
|
||||
~watermarkSource: text
|
||||
~watermarkExpression: CONFIDENTIAL
|
||||
~watermarkPages:
|
||||
|
||||
@@ -51,9 +51,19 @@ body:multipart-form {
|
||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
~allowPrinting: false
|
||||
~allowCopying: false
|
||||
~allowModifying: false
|
||||
~allowAnnotating: false
|
||||
~allowFillingForms: false
|
||||
~allowAssembling: false
|
||||
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||
~embeds: @file(../test/integration/testdata/embed_2.xml)
|
||||
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
||||
~facturxXml: @file(../../test/integration/testdata/embed_1.xml)
|
||||
~facturxConformanceLevel: EN 16931
|
||||
~facturxDocumentType: INVOICE
|
||||
~facturxVersion: 1.0
|
||||
~watermarkSource: text
|
||||
~watermarkExpression: CONFIDENTIAL
|
||||
~watermarkPages:
|
||||
|
||||
@@ -50,9 +50,19 @@ body:multipart-form {
|
||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
~allowPrinting: false
|
||||
~allowCopying: false
|
||||
~allowModifying: false
|
||||
~allowAnnotating: false
|
||||
~allowFillingForms: false
|
||||
~allowAssembling: false
|
||||
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||
~embeds: @file(../test/integration/testdata/embed_2.xml)
|
||||
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
||||
~facturxXml: @file(../test/integration/testdata/embed_1.xml)
|
||||
~facturxConformanceLevel: EN 16931
|
||||
~facturxDocumentType: INVOICE
|
||||
~facturxVersion: 1.0
|
||||
~watermarkSource: text
|
||||
~watermarkExpression: CONFIDENTIAL
|
||||
~watermarkPages:
|
||||
|
||||
@@ -67,9 +67,19 @@ body:multipart-form {
|
||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
~allowPrinting: false
|
||||
~allowCopying: false
|
||||
~allowModifying: false
|
||||
~allowAnnotating: false
|
||||
~allowFillingForms: false
|
||||
~allowAssembling: false
|
||||
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||
~embeds: @file(../test/integration/testdata/embed_2.xml)
|
||||
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
||||
~facturxXml: @file(../test/integration/testdata/embed_1.xml)
|
||||
~facturxConformanceLevel: EN 16931
|
||||
~facturxDocumentType: INVOICE
|
||||
~facturxVersion: 1.0
|
||||
~watermarkSource: text
|
||||
~watermarkExpression: CONFIDENTIAL
|
||||
~watermarkPages:
|
||||
|
||||
@@ -15,6 +15,10 @@ body:multipart-form {
|
||||
embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||
embeds: @file(../test/integration/testdata/embed_2.xml)
|
||||
embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
||||
~facturxXml: @file(../test/integration/testdata/embed_1.xml)
|
||||
~facturxConformanceLevel: EN 16931
|
||||
~facturxDocumentType: INVOICE
|
||||
~facturxVersion: 1.0
|
||||
~downloadFrom: [{"url":"https://example.com/attachment.xml","embedded":true}]
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,12 @@ body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/page_1.pdf)
|
||||
userPassword: secret123
|
||||
~ownerPassword: owner456
|
||||
~allowPrinting: false
|
||||
~allowCopying: false
|
||||
~allowModifying: false
|
||||
~allowAnnotating: false
|
||||
~allowFillingForms: false
|
||||
~allowAssembling: false
|
||||
}
|
||||
|
||||
headers {
|
||||
|
||||
29
.bruno/PDF Engines/Factur-X/Inject Factur-X XMP.bru
Normal file
29
.bruno/PDF Engines/Factur-X/Inject Factur-X XMP.bru
Normal file
@@ -0,0 +1,29 @@
|
||||
meta {
|
||||
name: Inject Factur-X XMP
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/pdfengines/factur-x
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/page_1.pdf)
|
||||
facturxXml: @file(../../test/integration/testdata/embed_1.xml)
|
||||
facturxConformanceLevel: EN 16931
|
||||
~facturxDocumentType: INVOICE
|
||||
~facturxVersion: 1.0
|
||||
}
|
||||
|
||||
headers {
|
||||
~Gotenberg-Output-Filename: factur-x
|
||||
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
|
||||
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
|
||||
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
|
||||
~Gotenberg-Webhook-Method: POST
|
||||
~Gotenberg-Webhook-Error-Method: POST
|
||||
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
|
||||
}
|
||||
@@ -21,6 +21,12 @@ body:multipart-form {
|
||||
~bookmarks: [{"title":"Page 1","page":1},{"title":"Page 2","page":2}]
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
~allowPrinting: false
|
||||
~allowCopying: false
|
||||
~allowModifying: false
|
||||
~allowAnnotating: false
|
||||
~allowFillingForms: false
|
||||
~allowAssembling: false
|
||||
~watermarkSource: text
|
||||
~watermarkExpression: CONFIDENTIAL
|
||||
~watermarkPages:
|
||||
@@ -31,6 +37,10 @@ body:multipart-form {
|
||||
~stampOptions: {"scale":"0.5 abs","rot":"45"}
|
||||
~rotateAngle: 90
|
||||
~rotatePages:
|
||||
~facturxXml: @file(../../test/integration/testdata/embed_1.xml)
|
||||
~facturxConformanceLevel: EN 16931
|
||||
~facturxDocumentType: INVOICE
|
||||
~facturxVersion: 1.0
|
||||
}
|
||||
|
||||
headers {
|
||||
|
||||
@@ -21,6 +21,12 @@ body:multipart-form {
|
||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
~allowPrinting: false
|
||||
~allowCopying: false
|
||||
~allowModifying: false
|
||||
~allowAnnotating: false
|
||||
~allowFillingForms: false
|
||||
~allowAssembling: false
|
||||
~watermarkSource: text
|
||||
~watermarkExpression: CONFIDENTIAL
|
||||
~watermarkPages:
|
||||
@@ -31,6 +37,10 @@ body:multipart-form {
|
||||
~stampOptions: {"scale":"0.5 abs","rot":"45"}
|
||||
~rotateAngle: 90
|
||||
~rotatePages:
|
||||
~facturxXml: @file(../../test/integration/testdata/embed_1.xml)
|
||||
~facturxConformanceLevel: EN 16931
|
||||
~facturxDocumentType: INVOICE
|
||||
~facturxVersion: 1.0
|
||||
}
|
||||
|
||||
headers {
|
||||
|
||||
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
@@ -12,6 +12,12 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
ignore:
|
||||
# Held at v0.14.2: v0.15.x breaks the headless print-mode paint pipeline
|
||||
# (rAF / ResizeObserver / IntersectionObserver stop firing, blank charts).
|
||||
# See https://github.com/gotenberg/gotenberg/issues/1535.
|
||||
- dependency-name: "github.com/chromedp/chromedp"
|
||||
- dependency-name: "github.com/chromedp/cdproto"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
|
||||
12
.github/workflows/continuous-delivery.yml
vendored
12
.github/workflows/continuous-delivery.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build and push
|
||||
id: build_push
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build and push
|
||||
id: build_push
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build and push
|
||||
id: build_push
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build and push
|
||||
id: build_push
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build and push
|
||||
id: build_push
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Merge
|
||||
uses: ./.github/actions/merge
|
||||
|
||||
36
.github/workflows/continuous-integration.yml
vendored
36
.github/workflows/continuous-integration.yml
vendored
@@ -21,10 +21,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
@@ -38,10 +38,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
@@ -59,10 +59,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
@@ -117,7 +117,7 @@ jobs:
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
@@ -147,7 +147,7 @@ jobs:
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
@@ -253,7 +253,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Merge
|
||||
uses: ./.github/actions/merge
|
||||
@@ -322,7 +322,7 @@ jobs:
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
@@ -351,7 +351,7 @@ jobs:
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
@@ -380,7 +380,7 @@ jobs:
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
@@ -409,7 +409,7 @@ jobs:
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
@@ -438,7 +438,7 @@ jobs:
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
@@ -460,7 +460,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Merge
|
||||
uses: ./.github/actions/merge
|
||||
|
||||
2
.github/workflows/pull-request-cleanup.yml
vendored
2
.github/workflows/pull-request-cleanup.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Cleanup
|
||||
uses: ./.github/actions/clean
|
||||
|
||||
@@ -85,6 +85,15 @@ If a change violates backward compatibility, flag it as a breaking change in the
|
||||
- No panics in production code paths.
|
||||
- Validate input defensively.
|
||||
|
||||
### Error messages
|
||||
|
||||
Client- and operator-facing error messages state what failed, why when non-obvious, and how to fix it when a fix exists. Internal errors (the wrapped `fmt.Errorf` chains that only reach logs) are exempt; keep them precise and technical.
|
||||
|
||||
- Client (HTTP response body): name the offending form field and its valid values. Never return a bare `http.StatusText()`.
|
||||
- Operator (startup, `Provision`, `Validate`): name the environment variable or flag to set, plus the path or value checked.
|
||||
- Security and filtering errors stay generic for clients. Don't reveal allow/deny lists or private-IP policy. Log the specific reason for operators.
|
||||
- No hedging ("while others may have failed"). No raw `os.Stat` or exec output in the human-facing remedy.
|
||||
|
||||
### Logging
|
||||
|
||||
Use `gotenberg.Logger(mod)` to get the module's slog logger during `Provision()`. All log calls must be context-aware: `logger.DebugContext(ctx, msg)`, `logger.InfoContext(ctx, msg)`, `logger.ErrorContext(ctx, msg)`. This propagates trace/span IDs into structured logs when OpenTelemetry is active.
|
||||
|
||||
17
Makefile
17
Makefile
@@ -18,6 +18,8 @@ GOTENBERG_BUILD_DEBUG_DATA=true
|
||||
API_PORT=3000
|
||||
API_PORT_FROM_ENV=
|
||||
API_BIND_IP=
|
||||
API_TLS_CERT_FILE=
|
||||
API_TLS_KEY_FILE=
|
||||
API_START_TIMEOUT=30s
|
||||
API_TIMEOUT=30s
|
||||
API_BODY_LIMIT=
|
||||
@@ -28,6 +30,9 @@ GOTENBERG_API_BASIC_AUTH_USERNAME=
|
||||
GOTENBERG_API_BASIC_AUTH_PASSWORD=
|
||||
API_DOWNLOAD_FROM_ALLOW_LIST=
|
||||
API_DOWNLOAD_FROM_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)
|
||||
API_DOWNLOAD_FROM_DENY_PRIVATE_IPS=false
|
||||
API_DOWNLOAD_FROM_DENY_PUBLIC_IPS=false
|
||||
API_DOWNLOAD_FROM_ENABLE_ENVIRONMENT_PROXY=false
|
||||
API_DOWNLOAD_FROM_MAX_RETRY=4
|
||||
API_DISABLE_DOWNLOAD_FROM=false
|
||||
API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=true
|
||||
@@ -47,8 +52,11 @@ CHROMIUM_DISABLE_WEB_SECURITY=false
|
||||
CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false
|
||||
CHROMIUM_HOST_RESOLVER_RULES=
|
||||
CHROMIUM_PROXY_SERVER=
|
||||
CHROMIUM_ENABLE_ENVIRONMENT_PROXY=false
|
||||
CHROMIUM_ALLOW_LIST=
|
||||
CHROMIUM_DENY_LIST=^file:(?!//\/tmp/).*
|
||||
CHROMIUM_DENY_PRIVATE_IPS=false
|
||||
CHROMIUM_DENY_PUBLIC_IPS=false
|
||||
CHROMIUM_CLEAR_CACHE=false
|
||||
CHROMIUM_CLEAR_COOKIES=false
|
||||
CHROMIUM_DISABLE_JAVASCRIPT=false
|
||||
@@ -60,11 +68,15 @@ LIBREOFFICE_AUTO_START=false
|
||||
LIBREOFFICE_START_TIMEOUT=20s
|
||||
LIBREOFFICE_ALLOW_LIST=
|
||||
LIBREOFFICE_DENY_LIST=
|
||||
LIBREOFFICE_DENY_PRIVATE_IPS=false
|
||||
LIBREOFFICE_DENY_PUBLIC_IPS=false
|
||||
LIBREOFFICE_ENABLE_ENVIRONMENT_PROXY=false
|
||||
LIBREOFFICE_DISABLE_ROUTES=false
|
||||
LOG_LEVEL=info
|
||||
LOG_FIELDS_PREFIX=
|
||||
LOG_STD_FORMAT=auto
|
||||
LOG_STD_ENABLE_GCP_FIELDS=false
|
||||
LOG_STD_LEVEL_CASE=lower
|
||||
PDFENGINES_DISABLE_ROUTES=false
|
||||
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
|
||||
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
|
||||
@@ -80,6 +92,7 @@ PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
|
||||
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
|
||||
PDFENGINES_EMBED_ENGINES=qpdf,pdfcpu
|
||||
PDFENGINES_EMBED_METADATA_ENGINES=qpdf
|
||||
PDFENGINES_FACTUR_X_ENGINES=qpdf
|
||||
PROMETHEUS_NAMESPACE=gotenberg
|
||||
PROMETHEUS_COLLECT_INTERVAL=1s
|
||||
PROMETHEUS_DISABLE_ROUTE_TELEMETRY=true
|
||||
@@ -95,6 +108,9 @@ OTEL_EXPORTER_OTLP_INSECURE=true
|
||||
WEBHOOK_ENABLE_SYNC_MODE=false
|
||||
WEBHOOK_ALLOW_LIST=
|
||||
WEBHOOK_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)
|
||||
WEBHOOK_DENY_PRIVATE_IPS=false
|
||||
WEBHOOK_DENY_PUBLIC_IPS=false
|
||||
WEBHOOK_ENABLE_ENVIRONMENT_PROXY=false
|
||||
WEBHOOK_MAX_RETRY=4
|
||||
WEBHOOK_RETRY_MIN_WAIT=1s
|
||||
WEBHOOK_RETRY_MAX_WAIT=30s
|
||||
@@ -156,6 +172,7 @@ NO_CONCURRENCY=false
|
||||
# stamp
|
||||
# pdfengines-rotate
|
||||
# rotate
|
||||
# factur-x
|
||||
# pdfengines-bookmarks
|
||||
# bookmarks
|
||||
# prometheus-metrics
|
||||
|
||||
@@ -88,7 +88,7 @@ RUN apt-get update -qq \
|
||||
|
||||
WORKDIR /downloads
|
||||
|
||||
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.2.0/unoconv -o unoconverter \
|
||||
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.3.0/unoconv -o unoconverter \
|
||||
&& chmod +x unoconverter
|
||||
|
||||
RUN curl -o pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" \
|
||||
@@ -125,6 +125,7 @@ RUN groupadd --gid "$GOTENBERG_USER_GID" gotenberg \
|
||||
RUN apt-get update -qq \
|
||||
&& apt-get upgrade -yqq \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
|
||||
ca-certificates \
|
||||
tini \
|
||||
# Many users rely on curl for Docker health checks.
|
||||
curl \
|
||||
@@ -189,6 +190,19 @@ ENV QPDF_BIN_PATH=/usr/bin/qpdf
|
||||
ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool
|
||||
ENV PDFCPU_BIN_PATH=/usr/bin/pdfcpu
|
||||
|
||||
# Capture backing-binary versions at build time so the running process reports
|
||||
# them on traces without spawning the binaries at startup or per request.
|
||||
# See pkg/gotenberg/buildversions.go. Chromium and LibreOffice are captured in
|
||||
# the variant stages below, where they are installed.
|
||||
ENV GOTENBERG_VERSIONS_DIR_PATH=/opt/gotenberg/versions
|
||||
|
||||
COPY --link build/capture-version.sh /opt/gotenberg/capture-version.sh
|
||||
|
||||
RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" qpdf "$QPDF_BIN_PATH" --version \
|
||||
&& bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" exiftool "$EXIFTOOL_BIN_PATH" -ver \
|
||||
&& bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" pdftk "$PDFTK_BIN_PATH" --version \
|
||||
&& bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" pdfcpu "$PDFCPU_BIN_PATH" version
|
||||
|
||||
# OpenTelemetry defaults (noop - no telemetry overhead unless explicitly enabled).
|
||||
ENV OTEL_TRACES_EXPORTER=none
|
||||
ENV OTEL_METRICS_EXPORTER=none
|
||||
@@ -268,6 +282,10 @@ ENV CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data
|
||||
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
|
||||
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
|
||||
|
||||
# Capture Chromium and LibreOffice versions now that both are installed.
|
||||
RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" chromium "$CHROMIUM_BIN_PATH" --version \
|
||||
&& bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" libreoffice-api "$LIBREOFFICE_BIN_PATH" --version
|
||||
|
||||
USER gotenberg
|
||||
WORKDIR /home/gotenberg
|
||||
|
||||
@@ -328,6 +346,9 @@ ENV CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data
|
||||
# No LibreOffice in this variant; override the default to use all available engines.
|
||||
ENV PDFENGINES_CONVERT_ENGINES=
|
||||
|
||||
# Capture the Chromium version now that it is installed.
|
||||
RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" chromium "$CHROMIUM_BIN_PATH" --version
|
||||
|
||||
USER gotenberg
|
||||
WORKDIR /home/gotenberg
|
||||
|
||||
@@ -382,6 +403,9 @@ COPY --link --from=downloader-stage /downloads/unoconverter /usr/bin/unoconverte
|
||||
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
|
||||
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
|
||||
|
||||
# Capture the LibreOffice version now that it is installed.
|
||||
RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" libreoffice-api "$LIBREOFFICE_BIN_PATH" --version
|
||||
|
||||
USER gotenberg
|
||||
WORKDIR /home/gotenberg
|
||||
|
||||
|
||||
34
build/capture-version.sh
Normal file
34
build/capture-version.sh
Normal file
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Captures the version of a backing binary into a per-module file that the
|
||||
# running Gotenberg process reads via gotenberg.BuildVersion, so it never spawns
|
||||
# the binary just to report a version. This keeps cold start and the first
|
||||
# request cheap, which matters on serverless platforms.
|
||||
#
|
||||
# Failure-tolerant by design: a probe that errors writes an empty file, and the
|
||||
# runtime falls back to detecting the version live. A failing probe must never
|
||||
# fail the image build.
|
||||
#
|
||||
# Usage: capture-version.sh <output-dir> <module-id> <bin> [args...]
|
||||
set -u
|
||||
|
||||
dir="$1"
|
||||
id="$2"
|
||||
shift 2
|
||||
|
||||
mkdir -p "$dir"
|
||||
|
||||
# Run the probe once. On failure, keep going with empty output.
|
||||
raw="$("$@" 2>/dev/null)" || raw=""
|
||||
|
||||
case "$id" in
|
||||
pdfcpu)
|
||||
# pdfcpu prints "pdfcpu: <version>"; keep only the part the runtime parser
|
||||
# keeps so the recorded value matches the live-detection fallback.
|
||||
version="$(printf '%s\n' "$raw" | grep -m1 '^pdfcpu:' | sed 's/^pdfcpu:[[:space:]]*//')"
|
||||
;;
|
||||
*)
|
||||
version="$(printf '%s\n' "$raw" | head -n1)"
|
||||
;;
|
||||
esac
|
||||
|
||||
printf '%s' "$version" | tr -d '\r' >"$dir/$id"
|
||||
@@ -49,6 +49,7 @@ func Run() {
|
||||
fs.String("log-fields-prefix", "", "Prepend a specified prefix to each log field key")
|
||||
fs.String("log-std-format", gotenberg.AutoLoggingFormat, "Set the log format for standard output")
|
||||
fs.Bool("log-std-enable-gcp-fields", false, "Use GCP-compatible field names in log output")
|
||||
fs.String("log-std-level-case", gotenberg.LowerLevelCase, "Set the case of the level field in the standard output, either lower or upper")
|
||||
|
||||
// Deprecated logging flags.
|
||||
fs.String("log-format", gotenberg.AutoLoggingFormat, "Set the log format")
|
||||
@@ -123,6 +124,7 @@ func Run() {
|
||||
LogFieldsPrefix: parsedFlags.MustString("log-fields-prefix"),
|
||||
LogStdFormat: parsedFlags.MustDeprecatedString("log-format", "log-std-format"),
|
||||
LogStdEnableGcpFields: parsedFlags.MustDeprecatedBool("log-enable-gcp-fields", "log-std-enable-gcp-fields"),
|
||||
LogStdLevelCase: parsedFlags.MustString("log-std-level-case"),
|
||||
}
|
||||
// LogLevel uses its own flag, not the format flag.
|
||||
telemetryCfg.LogLevel = parsedFlags.MustString("log-level")
|
||||
|
||||
16
compose.yaml
16
compose.yaml
@@ -21,6 +21,8 @@ services:
|
||||
- "--api-port=${API_PORT}"
|
||||
- "--api-port-from-env=${API_PORT_FROM_ENV}"
|
||||
- "--api-bind-ip=${API_BIND_IP}"
|
||||
- "--api-tls-cert-file=${API_TLS_CERT_FILE}"
|
||||
- "--api-tls-key-file=${API_TLS_KEY_FILE}"
|
||||
- "--api-start-timeout=${API_START_TIMEOUT}"
|
||||
- "--api-timeout=${API_TIMEOUT}"
|
||||
- "--api-body-limit=${API_BODY_LIMIT}"
|
||||
@@ -29,6 +31,9 @@ services:
|
||||
- "--api-enable-basic-auth=${API_ENABLE_BASIC_AUTH}"
|
||||
- "--api-download-from-allow-list=${API_DOWNLOAD_FROM_ALLOW_LIST}"
|
||||
- "--api-download-from-deny-list=${API_DOWNLOAD_FROM_DENY_LIST}"
|
||||
- "--api-download-from-deny-private-ips=${API_DOWNLOAD_FROM_DENY_PRIVATE_IPS}"
|
||||
- "--api-download-from-deny-public-ips=${API_DOWNLOAD_FROM_DENY_PUBLIC_IPS}"
|
||||
- "--api-download-from-enable-environment-proxy=${API_DOWNLOAD_FROM_ENABLE_ENVIRONMENT_PROXY}"
|
||||
- "--api-download-from-max-retry=${API_DOWNLOAD_FROM_MAX_RETRY}"
|
||||
- "--api-disable-download-from=${API_DISABLE_DOWNLOAD_FROM}"
|
||||
- "--api-disable-health-check-route-telemetry=${API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY}"
|
||||
@@ -48,8 +53,11 @@ services:
|
||||
- "--chromium-allow-file-access-from-files=${CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES}"
|
||||
- "--chromium-host-resolver-rules=${CHROMIUM_HOST_RESOLVER_RULES}"
|
||||
- "--chromium-proxy-server=${CHROMIUM_PROXY_SERVER}"
|
||||
- "--chromium-enable-environment-proxy=${CHROMIUM_ENABLE_ENVIRONMENT_PROXY}"
|
||||
- "--chromium-allow-list=${CHROMIUM_ALLOW_LIST}"
|
||||
- "--chromium-deny-list=${CHROMIUM_DENY_LIST}"
|
||||
- "--chromium-deny-private-ips=${CHROMIUM_DENY_PRIVATE_IPS}"
|
||||
- "--chromium-deny-public-ips=${CHROMIUM_DENY_PUBLIC_IPS}"
|
||||
- "--chromium-clear-cache=${CHROMIUM_CLEAR_CACHE}"
|
||||
- "--chromium-clear-cookies=${CHROMIUM_CLEAR_COOKIES}"
|
||||
- "--chromium-disable-javascript=${CHROMIUM_DISABLE_JAVASCRIPT}"
|
||||
@@ -61,11 +69,15 @@ services:
|
||||
- "--libreoffice-start-timeout=${LIBREOFFICE_START_TIMEOUT}"
|
||||
- "--libreoffice-allow-list=${LIBREOFFICE_ALLOW_LIST}"
|
||||
- "--libreoffice-deny-list=${LIBREOFFICE_DENY_LIST}"
|
||||
- "--libreoffice-deny-private-ips=${LIBREOFFICE_DENY_PRIVATE_IPS}"
|
||||
- "--libreoffice-deny-public-ips=${LIBREOFFICE_DENY_PUBLIC_IPS}"
|
||||
- "--libreoffice-enable-environment-proxy=${LIBREOFFICE_ENABLE_ENVIRONMENT_PROXY}"
|
||||
- "--libreoffice-disable-routes=${LIBREOFFICE_DISABLE_ROUTES}"
|
||||
- "--log-level=${LOG_LEVEL}"
|
||||
- "--log-fields-prefix=${LOG_FIELDS_PREFIX}"
|
||||
- "--log-std-format=${LOG_STD_FORMAT}"
|
||||
- "--log-std-enable-gcp-fields=${LOG_STD_ENABLE_GCP_FIELDS}"
|
||||
- "--log-std-level-case=${LOG_STD_LEVEL_CASE}"
|
||||
- "--pdfengines-merge-engines=${PDFENGINES_MERGE_ENGINES}"
|
||||
- "--pdfengines-split-engines=${PDFENGINES_SPLIT_ENGINES}"
|
||||
- "--pdfengines-flatten-engines=${PDFENGINES_FLATTEN_ENGINES}"
|
||||
@@ -80,6 +92,7 @@ services:
|
||||
- "--pdfengines-rotate-engines=${PDFENGINES_ROTATE_ENGINES}"
|
||||
- "--pdfengines-embed-engines=${PDFENGINES_EMBED_ENGINES}"
|
||||
- "--pdfengines-embed-metadata-engines=${PDFENGINES_EMBED_METADATA_ENGINES}"
|
||||
- "--pdfengines-factur-x-engines=${PDFENGINES_FACTUR_X_ENGINES}"
|
||||
- "--pdfengines-disable-routes=${PDFENGINES_DISABLE_ROUTES}"
|
||||
- "--prometheus-namespace=${PROMETHEUS_NAMESPACE}"
|
||||
- "--prometheus-collect-interval=${PROMETHEUS_COLLECT_INTERVAL}"
|
||||
@@ -89,6 +102,9 @@ services:
|
||||
- "--webhook-enable-sync-mode=${WEBHOOK_ENABLE_SYNC_MODE}"
|
||||
- "--webhook-allow-list=${WEBHOOK_ALLOW_LIST}"
|
||||
- "--webhook-deny-list=${WEBHOOK_DENY_LIST}"
|
||||
- "--webhook-deny-private-ips=${WEBHOOK_DENY_PRIVATE_IPS}"
|
||||
- "--webhook-deny-public-ips=${WEBHOOK_DENY_PUBLIC_IPS}"
|
||||
- "--webhook-enable-environment-proxy=${WEBHOOK_ENABLE_ENVIRONMENT_PROXY}"
|
||||
- "--webhook-max-retry=${WEBHOOK_MAX_RETRY}"
|
||||
- "--webhook-retry-min-wait=${WEBHOOK_RETRY_MIN_WAIT}"
|
||||
- "--webhook-retry-max-wait=${WEBHOOK_RETRY_MAX_WAIT}"
|
||||
|
||||
55
go.mod
55
go.mod
@@ -4,24 +4,24 @@ go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/alexliesenfeld/health v0.8.1
|
||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
|
||||
github.com/chromedp/chromedp v0.14.2
|
||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d // pinned with chromedp v0.14.2, see below
|
||||
github.com/chromedp/chromedp v0.14.2 // pinned: v0.15.x breaks the headless print-mode paint pipeline (rAF / ResizeObserver / IntersectionObserver stop firing, blank charts). See https://github.com/gotenberg/gotenberg/issues/1535.
|
||||
github.com/cucumber/godog v0.15.1
|
||||
github.com/dlclark/regexp2 v1.12.0
|
||||
github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df
|
||||
github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8
|
||||
github.com/labstack/echo/v4 v4.15.2
|
||||
github.com/labstack/echo/v4 v4.15.4
|
||||
github.com/labstack/gommon v0.5.0
|
||||
github.com/mholt/archives v0.1.5
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/moby/moby/api v1.54.2
|
||||
github.com/moby/moby/client v0.4.1
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/shirou/gopsutil/v4 v4.26.4
|
||||
github.com/moby/moby/api v1.55.0
|
||||
github.com/moby/moby/client v0.5.1
|
||||
github.com/prometheus/client_golang v1.24.1
|
||||
github.com/shirou/gopsutil/v4 v4.26.6
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/testcontainers/testcontainers-go v0.42.0
|
||||
github.com/testcontainers/testcontainers-go v0.43.0
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0
|
||||
go.opentelemetry.io/otel v1.44.0
|
||||
@@ -31,10 +31,10 @@ require (
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0
|
||||
go.opentelemetry.io/otel/trace v1.44.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/text v0.37.0
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/term v0.45.0
|
||||
golang.org/x/text v0.40.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -65,8 +65,8 @@ require (
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
||||
github.com/ebitengine/purego v0.10.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 // indirect
|
||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
@@ -81,32 +81,32 @@ require (
|
||||
github.com/hashicorp/go-memdb v1.3.5 // indirect
|
||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/klauspost/compress v1.19.1 // indirect
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
||||
github.com/minio/minlz v1.1.1 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.2.0 // indirect
|
||||
github.com/moby/patternmatcher v0.6.1 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
github.com/moby/sys/sequential v0.7.0 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nwaples/rardecode/v2 v2.2.3 // indirect
|
||||
github.com/nwaples/rardecode/v2 v2.2.5 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.26 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.27 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/common v0.70.1 // indirect
|
||||
github.com/prometheus/otlptranslator v1.0.0 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/sorairolake/lzip-go v0.3.8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
@@ -132,14 +132,13 @@ require (
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/grpc v1.81.1 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 // indirect
|
||||
google.golang.org/grpc v1.82.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
100
go.sum
100
go.sum
@@ -72,10 +72,10 @@ github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/
|
||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 h1:NZBJxCpbHS1gzS6xAmyxbJznosZIIPk9IB42v62UvKA=
|
||||
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
||||
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
||||
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6 h1:nxP4pPoyqOAgX8lYDFCfl3DyKeXErCvSvhcyzwGV9CE=
|
||||
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -96,8 +96,8 @@ github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1
|
||||
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df h1:Mwihr/o+v4L5h56rwHLOE20+hh7Okhwno5BHz3zDuao=
|
||||
github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76 h1:Ltt9ldIaSYEsjA7sPY2c8r9dOmnKM1vlzhh3dxlhBHM=
|
||||
github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
@@ -130,8 +130,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
||||
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||
@@ -144,8 +144,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/labstack/echo/v4 v4.15.2 h1:nnh2sCzGCVYnU+wCisMPiYapEg/QVo/gcI9ePKg5/T4=
|
||||
github.com/labstack/echo/v4 v4.15.2/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI=
|
||||
github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs=
|
||||
github.com/labstack/echo/v4 v4.15.4/go.mod h1:CuMetKIRwsuO/qlAgMq+KTAalwGoB/h4tC+yPdrTj1g=
|
||||
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
|
||||
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
@@ -154,8 +154,8 @@ github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRt
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
|
||||
@@ -170,14 +170,14 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
||||
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
|
||||
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
|
||||
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
|
||||
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
||||
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw=
|
||||
github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM=
|
||||
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
|
||||
github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
@@ -186,35 +186,35 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nwaples/rardecode/v2 v2.2.3 h1:qaVuy3ChZDbAQZshPLjHeNJKF3Cru8uo9jmgveKIy2A=
|
||||
github.com/nwaples/rardecode/v2 v2.2.3/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
|
||||
github.com/nwaples/rardecode/v2 v2.2.5 h1:L5doqgGfQwI7qADJMqnkrSB86rpPsqQDrHeO0HWa5JY=
|
||||
github.com/nwaples/rardecode/v2 v2.2.5/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
|
||||
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
|
||||
github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
||||
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
|
||||
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
|
||||
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
|
||||
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY=
|
||||
github.com/shirou/gopsutil/v4 v4.26.4/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
|
||||
@@ -239,8 +239,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
|
||||
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
|
||||
github.com/testcontainers/testcontainers-go v0.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A=
|
||||
github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo=
|
||||
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
||||
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||
@@ -314,34 +314,34 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
|
||||
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 h1:g0RAkxK/smSu/iRwC/KIX1mwUoVJtk2OjbgaeS4DmUM=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 h1:9HZDLIdYBJXAnaFOr9WHrKVycfpY+75s9HGadC0305A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
107
package-lock.json
generated
107
package-lock.json
generated
@@ -5,9 +5,9 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"prettier": "3.8.3",
|
||||
"prettier": "3.9.6",
|
||||
"prettier-plugin-gherkin": "^3.1.3",
|
||||
"prettier-plugin-sh": "^0.18.0"
|
||||
"prettier-plugin-sh": "^0.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cucumber/gherkin": {
|
||||
@@ -34,15 +34,80 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@reteps/dockerfmt": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.5.2.tgz",
|
||||
"integrity": "sha512-Hbr7yen4fP5TxGM54ucXa4o5NwWXatJ6Bd9I8gp0PValYbI4Rug2Gu+rVv7K7o/efQc3F5ctqWJz47rYaa8zBw==",
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.5.4.tgz",
|
||||
"integrity": "sha512-HEGgXVVOb+JtGUSSzXl/XPKFIZjMDTUoHarCjaQdkY+cb5M9K/O3b5xm+x0IPIk3SfHurbc0bSgcFsQlzjitxA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"dockerfmt": "dist/launcher.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^v12.20.0 || ^14.13.0 || >=16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@reteps/dockerfmt-darwin-arm64": "0.5.4",
|
||||
"@reteps/dockerfmt-darwin-x64": "0.5.4",
|
||||
"@reteps/dockerfmt-linux-arm64": "0.5.4",
|
||||
"@reteps/dockerfmt-linux-x64": "0.5.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@reteps/dockerfmt-darwin-arm64": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt-darwin-arm64/-/dockerfmt-darwin-arm64-0.5.4.tgz",
|
||||
"integrity": "sha512-urMqV+dQyvVI8/WrXwClX9e1PEyS35wFdwJjpZYmL09AkV4Io5U1oam8UBKK7jZk0+YsdF88ay6e86Kn6DIyQg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@reteps/dockerfmt-darwin-x64": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt-darwin-x64/-/dockerfmt-darwin-x64-0.5.4.tgz",
|
||||
"integrity": "sha512-fJORy6DFxbgDiMqxpLTPZlb5KUY0Vq0iR4NGnyKnuYZ9LdZUS508DK2kt/AJ87/jIKNV1qRG0JXG1Tc6xdvWjw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@reteps/dockerfmt-linux-arm64": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt-linux-arm64/-/dockerfmt-linux-arm64-0.5.4.tgz",
|
||||
"integrity": "sha512-6pVakO06eXtDuvxy1Dnjs/gQyUoGGycle8PRSt5IFRwLi/AVaOQwfkfmW0WP8VH9wNUeti6BfJ3ksTn2G+XMxg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@reteps/dockerfmt-linux-x64": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt-linux-x64/-/dockerfmt-linux-x64-0.5.4.tgz",
|
||||
"integrity": "sha512-OD6SIlUV1D4TgJoTui3FMBAZsGbTSPYsiT0BKhD6jMUcJb3GpFTa7dY9rL8rP9FUqfL7OTHVUGUOL4Rh64Olog==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||
@@ -58,9 +123,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
|
||||
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
||||
"version": "3.9.6",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
|
||||
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -86,14 +151,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier-plugin-sh": {
|
||||
"version": "0.18.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.18.1.tgz",
|
||||
"integrity": "sha512-uZmU22wBMevjh3rmCatNQqiEer2+5KLa0xYCBX6zQQUQkcNzVL+s6FbPKK6ZSUNUbQk6jMAcQHrYPvuL2W6ihQ==",
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.19.0.tgz",
|
||||
"integrity": "sha512-39VXFZH/cOGtcuu8aeSvqp/hhwomOR4QroZUj+jBz2cNb3os9s0sqFZSNlYts6jdtLLDU7D2YT3Z1+abtb7adQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reteps/dockerfmt": "^0.5.1",
|
||||
"sh-syntax": "^0.5.8"
|
||||
"@reteps/dockerfmt": "^0.5.4",
|
||||
"sh-syntax": "^0.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
@@ -113,14 +178,11 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/sh-syntax": {
|
||||
"version": "0.5.8",
|
||||
"resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.5.8.tgz",
|
||||
"integrity": "sha512-JfVoxf4FxQI5qpsPbkHhZo+n6N9YMJobyl4oGEUBb/31oQYlgTjkXQD8PBiafS2UbWoxrTO0Z5PJUBXEPAG1Zw==",
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.6.0.tgz",
|
||||
"integrity": "sha512-52VK6z/cdZHv7UURjIcwfBUQZrAhIEEe0bY4lrkfypjnFIKsDZdD3Uaz/dBiw/sF8BeX0Mssv140s8EnrsJ9dQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
@@ -128,13 +190,6 @@
|
||||
"url": "https://opencollective.com/sh-syntax"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"prettier": "3.8.3",
|
||||
"prettier": "3.9.6",
|
||||
"prettier-plugin-gherkin": "^3.1.3",
|
||||
"prettier-plugin-sh": "^0.18.0"
|
||||
"prettier-plugin-sh": "^0.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
53
pkg/gotenberg/attrs.go
Normal file
53
pkg/gotenberg/attrs.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// maxAttrRunes bounds the length of a string span attribute to keep payload
|
||||
// size and backend cardinality in check.
|
||||
const maxAttrRunes = 256
|
||||
|
||||
// CapAttr truncates s to at most [maxAttrRunes] runes, appending an ellipsis
|
||||
// when it shortens the value. It is multibyte-safe.
|
||||
func CapAttr(s string) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= maxAttrRunes {
|
||||
return s
|
||||
}
|
||||
return string(runes[:maxAttrRunes-1]) + "…"
|
||||
}
|
||||
|
||||
// RedactURL parses raw and returns a redacted, length-capped form safe to use
|
||||
// as a span attribute or event value. Userinfo, query, and fragment are
|
||||
// dropped because they may carry credentials or other sensitive data. It
|
||||
// returns an empty string when raw is empty or cannot be parsed.
|
||||
func RedactURL(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
parsed.User = nil
|
||||
parsed.RawQuery = ""
|
||||
parsed.ForceQuery = false
|
||||
parsed.Fragment = ""
|
||||
parsed.RawFragment = ""
|
||||
|
||||
return CapAttr(parsed.String())
|
||||
}
|
||||
|
||||
// MapEnum returns value when it belongs to allowed, otherwise "other". It keeps
|
||||
// a span attribute or metric dimension bounded even when an upstream tool
|
||||
// introduces a new enum value.
|
||||
func MapEnum(value string, allowed ...string) string {
|
||||
if slices.Contains(allowed, value) {
|
||||
return value
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
62
pkg/gotenberg/attrs_test.go
Normal file
62
pkg/gotenberg/attrs_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRedactURL(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"strips userinfo query fragment", "https://user:pass@example.com/path?token=secret#frag", "https://example.com/path"},
|
||||
{"keeps host and path", "http://example.com/a/b", "http://example.com/a/b"},
|
||||
{"parse error", "http://example.com/%zz", ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := RedactURL(tc.raw); got != tc.want {
|
||||
t.Errorf("RedactURL(%q) = %q, want %q", tc.raw, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactURL_Caps(t *testing.T) {
|
||||
raw := "https://example.com/" + strings.Repeat("a", 400)
|
||||
got := RedactURL(raw)
|
||||
if n := len([]rune(got)); n != maxAttrRunes {
|
||||
t.Errorf("expected capped length %d, got %d", maxAttrRunes, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapAttr(t *testing.T) {
|
||||
t.Run("short unchanged", func(t *testing.T) {
|
||||
if got := CapAttr("short"); got != "short" {
|
||||
t.Errorf("expected unchanged, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multibyte truncated to rune cap", func(t *testing.T) {
|
||||
got := CapAttr(strings.Repeat("é", 400))
|
||||
if n := len([]rune(got)); n != maxAttrRunes {
|
||||
t.Errorf("expected %d runes, got %d", maxAttrRunes, n)
|
||||
}
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Error("expected an ellipsis suffix on a truncated value")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMapEnum(t *testing.T) {
|
||||
allowed := []string{"document", "stylesheet", "script"}
|
||||
|
||||
if got := MapEnum("script", allowed...); got != "script" {
|
||||
t.Errorf("expected member passthrough, got %q", got)
|
||||
}
|
||||
if got := MapEnum("websocket", allowed...); got != "other" {
|
||||
t.Errorf("expected non-member to map to other, got %q", got)
|
||||
}
|
||||
}
|
||||
50
pkg/gotenberg/buildversions.go
Normal file
50
pkg/gotenberg/buildversions.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BuildVersionsDirPathEnvVar names the environment variable holding the
|
||||
// absolute path to a directory of build-time version files. The Gotenberg image
|
||||
// writes one file per module there, named by module ID and holding the version
|
||||
// string of that module's backing binary, captured right after the binary is
|
||||
// installed. The running process reads these files instead of executing the
|
||||
// binaries, which keeps startup and the first request cheap.
|
||||
const BuildVersionsDirPathEnvVar = "GOTENBERG_VERSIONS_DIR_PATH"
|
||||
|
||||
// BuildVersion returns the build-time version captured for the module with the
|
||||
// given ID. The boolean is false when no version was captured, which is the
|
||||
// case for local or non-Docker builds where the directory is absent. A module
|
||||
// uses it to avoid spawning its backing binary just to report a version.
|
||||
//
|
||||
// It is defensive: an unset variable, a missing or unreadable file, or an empty
|
||||
// value all yield ("", false), so the caller falls back to detecting the
|
||||
// version at runtime. See [BuildVersionsDirPathEnvVar].
|
||||
func BuildVersion(moduleID string) (string, bool) {
|
||||
dir := os.Getenv(BuildVersionsDirPathEnvVar)
|
||||
if dir == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Module IDs are fixed internal constants, never paths. Guard anyway so a
|
||||
// stray separator can't escape the versions directory.
|
||||
if moduleID != filepath.Base(moduleID) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// The directory comes from a trusted operator-set environment variable,
|
||||
// mirroring how engines exec their env-configured binaries.
|
||||
b, err := os.ReadFile(filepath.Join(dir, moduleID)) //nolint:gosec
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
version := strings.TrimSpace(string(b))
|
||||
if version == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return version, true
|
||||
}
|
||||
81
pkg/gotenberg/buildversions_test.go
Normal file
81
pkg/gotenberg/buildversions_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildVersion(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
fileBody string
|
||||
writeFile bool
|
||||
setEnv bool
|
||||
moduleID string
|
||||
wantValue string
|
||||
wantOk bool
|
||||
}{
|
||||
{
|
||||
scenario: "version present",
|
||||
fileBody: "Chromium 146.0",
|
||||
writeFile: true,
|
||||
setEnv: true,
|
||||
moduleID: "chromium",
|
||||
wantValue: "Chromium 146.0",
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
scenario: "value trimmed",
|
||||
fileBody: " qpdf version 11.9.0 \n",
|
||||
writeFile: true,
|
||||
setEnv: true,
|
||||
moduleID: "qpdf",
|
||||
wantValue: "qpdf version 11.9.0",
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
scenario: "empty file falls back",
|
||||
fileBody: " \n",
|
||||
writeFile: true,
|
||||
setEnv: true,
|
||||
moduleID: "pdftk",
|
||||
wantValue: "",
|
||||
wantOk: false,
|
||||
},
|
||||
{
|
||||
scenario: "missing file falls back",
|
||||
writeFile: false,
|
||||
setEnv: true,
|
||||
moduleID: "exiftool",
|
||||
wantValue: "",
|
||||
wantOk: false,
|
||||
},
|
||||
{
|
||||
scenario: "env unset falls back",
|
||||
setEnv: false,
|
||||
moduleID: "chromium",
|
||||
wantValue: "",
|
||||
wantOk: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.setEnv {
|
||||
dir := t.TempDir()
|
||||
if tc.writeFile {
|
||||
if err := os.WriteFile(filepath.Join(dir, tc.moduleID), []byte(tc.fileBody), 0o600); err != nil {
|
||||
t.Fatalf("write version file: %v", err)
|
||||
}
|
||||
}
|
||||
t.Setenv(BuildVersionsDirPathEnvVar, dir)
|
||||
} else {
|
||||
t.Setenv(BuildVersionsDirPathEnvVar, "")
|
||||
}
|
||||
|
||||
value, ok := BuildVersion(tc.moduleID)
|
||||
if value != tc.wantValue || ok != tc.wantOk {
|
||||
t.Errorf("BuildVersion(%q) = (%q, %t), want (%q, %t)", tc.moduleID, value, ok, tc.wantValue, tc.wantOk)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,14 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// Cmd wraps an [exec.Cmd].
|
||||
@@ -92,11 +98,44 @@ func (cmd *Cmd) Wait() error {
|
||||
|
||||
// Exec executes the command and waits for its completion or until the context
|
||||
// is done. In any case, it kills the unix process and all its children.
|
||||
//
|
||||
// When the context carries an active trace span, Exec records a
|
||||
// "process.exec" client span around the execution. It is the single
|
||||
// instrumentation point for every short-lived external binary (soffice, pdftk,
|
||||
// qpdf, exiftool, pdfcpu). The span is skipped when there is no active parent,
|
||||
// so process starts performed off the request path do not emit orphan roots.
|
||||
func (cmd *Cmd) Exec() (int, error) {
|
||||
if cmd.ctx == nil {
|
||||
return 10, errors.New("nil context")
|
||||
}
|
||||
|
||||
var span trace.Span
|
||||
if trace.SpanContextFromContext(cmd.ctx).IsValid() {
|
||||
_, span = Tracer().Start(cmd.ctx, "process.exec",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ProcessExecutableName(filepath.Base(cmd.process.Path))),
|
||||
)
|
||||
defer span.End()
|
||||
}
|
||||
|
||||
code, err := cmd.exec()
|
||||
|
||||
if span != nil {
|
||||
span.SetAttributes(attribute.Int("process.exit.code", code))
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
span.SetAttributes(semconv.ErrorTypeKey.String(execErrorType(cmd.ctx)))
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
}
|
||||
}
|
||||
|
||||
return code, err
|
||||
}
|
||||
|
||||
// exec runs the command and returns its exit code and error.
|
||||
func (cmd *Cmd) exec() (int, error) {
|
||||
err := cmd.Start()
|
||||
if err != nil {
|
||||
if cmd.process.ProcessState == nil {
|
||||
@@ -138,6 +177,19 @@ func (cmd *Cmd) Exec() (int, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// execErrorType maps an execution failure to a bounded semconv error.type
|
||||
// value.
|
||||
func execErrorType(ctx context.Context) string {
|
||||
switch {
|
||||
case errors.Is(ctx.Err(), context.DeadlineExceeded):
|
||||
return "context_deadline_exceeded"
|
||||
case errors.Is(ctx.Err(), context.Canceled):
|
||||
return "context_canceled"
|
||||
default:
|
||||
return "process_error"
|
||||
}
|
||||
}
|
||||
|
||||
// pipeOutput creates logs entries according to the process stdout and stderr.
|
||||
// It does nothing if the logging level is not debug.
|
||||
func (cmd *Cmd) pipeOutput() error {
|
||||
|
||||
136
pkg/gotenberg/cmd_test.go
Normal file
136
pkg/gotenberg/cmd_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
)
|
||||
|
||||
func newTestSpanRecorder(t *testing.T) *tracetest.SpanRecorder {
|
||||
t.Helper()
|
||||
recorder := tracetest.NewSpanRecorder()
|
||||
provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
|
||||
previous := otel.GetTracerProvider()
|
||||
otel.SetTracerProvider(provider)
|
||||
t.Cleanup(func() { otel.SetTracerProvider(previous) })
|
||||
return recorder
|
||||
}
|
||||
|
||||
func findSpan(recorder *tracetest.SpanRecorder, name string) sdktrace.ReadOnlySpan {
|
||||
for _, s := range recorder.Ended() {
|
||||
if s.Name() == name {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func spanAttr(span sdktrace.ReadOnlySpan, key string) (attribute.Value, bool) {
|
||||
for _, kv := range span.Attributes() {
|
||||
if string(kv.Key) == key {
|
||||
return kv.Value, true
|
||||
}
|
||||
}
|
||||
return attribute.Value{}, false
|
||||
}
|
||||
|
||||
func TestCmd_Exec_NilContext(t *testing.T) {
|
||||
cmd := Command(slog.New(slog.DiscardHandler), "true")
|
||||
|
||||
code, err := cmd.Exec()
|
||||
if err == nil {
|
||||
t.Error("expected an error for a nil context")
|
||||
}
|
||||
if code != 10 {
|
||||
t.Errorf("expected code 10, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmd_Exec_NoParentSpanProducesNoSpan(t *testing.T) {
|
||||
recorder := newTestSpanRecorder(t)
|
||||
|
||||
cmd, err := CommandContext(context.Background(), slog.New(slog.DiscardHandler), "sh", "-c", "exit 0")
|
||||
if err != nil {
|
||||
t.Fatalf("create command: %v", err)
|
||||
}
|
||||
|
||||
code, err := cmd.Exec()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if code != 0 {
|
||||
t.Errorf("expected code 0, got %d", code)
|
||||
}
|
||||
if n := len(recorder.Ended()); n != 0 {
|
||||
t.Errorf("expected no span without an active parent, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmd_Exec_RecordsSpanOnSuccess(t *testing.T) {
|
||||
recorder := newTestSpanRecorder(t)
|
||||
|
||||
ctx, parent := otel.Tracer("test").Start(context.Background(), "parent")
|
||||
cmd, err := CommandContext(ctx, slog.New(slog.DiscardHandler), "sh", "-c", "exit 0")
|
||||
if err != nil {
|
||||
t.Fatalf("create command: %v", err)
|
||||
}
|
||||
|
||||
code, err := cmd.Exec()
|
||||
parent.End()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if code != 0 {
|
||||
t.Errorf("expected code 0, got %d", code)
|
||||
}
|
||||
|
||||
span := findSpan(recorder, "process.exec")
|
||||
if span == nil {
|
||||
t.Fatal("expected a process.exec span to be recorded")
|
||||
}
|
||||
if span.Status().Code != codes.Ok {
|
||||
t.Errorf("expected status Ok, got %v", span.Status().Code)
|
||||
}
|
||||
if name, ok := spanAttr(span, "process.executable.name"); !ok || name.AsString() != "sh" {
|
||||
t.Errorf("expected process.executable.name=sh, got %q (present=%t)", name.AsString(), ok)
|
||||
}
|
||||
if exit, ok := spanAttr(span, "process.exit.code"); !ok || exit.AsInt64() != 0 {
|
||||
t.Errorf("expected process.exit.code=0, got %d (present=%t)", exit.AsInt64(), ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmd_Exec_RecordsSpanOnError(t *testing.T) {
|
||||
recorder := newTestSpanRecorder(t)
|
||||
|
||||
ctx, parent := otel.Tracer("test").Start(context.Background(), "parent")
|
||||
cmd, err := CommandContext(ctx, slog.New(slog.DiscardHandler), "sh", "-c", "exit 3")
|
||||
if err != nil {
|
||||
t.Fatalf("create command: %v", err)
|
||||
}
|
||||
|
||||
code, err := cmd.Exec()
|
||||
parent.End()
|
||||
if err == nil {
|
||||
t.Error("expected an error for a non-zero exit code")
|
||||
}
|
||||
if code != 3 {
|
||||
t.Errorf("expected exit code 3, got %d", code)
|
||||
}
|
||||
|
||||
span := findSpan(recorder, "process.exec")
|
||||
if span == nil {
|
||||
t.Fatal("expected a process.exec span to be recorded")
|
||||
}
|
||||
if span.Status().Code != codes.Error {
|
||||
t.Errorf("expected status Error, got %v", span.Status().Code)
|
||||
}
|
||||
if et, ok := spanAttr(span, "error.type"); !ok || et.AsString() != "process_error" {
|
||||
t.Errorf("expected error.type=process_error, got %q (present=%t)", et.AsString(), ok)
|
||||
}
|
||||
}
|
||||
52
pkg/gotenberg/errortype.go
Normal file
52
pkg/gotenberg/errortype.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// Engine-agnostic, low-cardinality error.type values shared by the conversion
|
||||
// engines. They are safe to use both as the semconv error.type span attribute
|
||||
// and as bounded metric label values.
|
||||
const (
|
||||
ErrorTypeTimeout = "timeout"
|
||||
ErrorTypeContextCancelled = "context_cancelled"
|
||||
ErrorTypeQueueSizeExceeded = "queue_size_exceeded"
|
||||
ErrorTypeProcessRestarting = "process_restarting"
|
||||
ErrorTypeInvalidInput = "invalid_input"
|
||||
ErrorTypeUnknown = "unknown"
|
||||
)
|
||||
|
||||
// ClassifyError maps err to a bounded, engine-agnostic error.type value. It
|
||||
// recognizes the failure modes shared by every engine: deadline, cancellation,
|
||||
// queue saturation, and process restart. It returns an empty string for a nil
|
||||
// error and [ErrorTypeUnknown] for anything it does not recognize, leaving
|
||||
// engine-specific refinement (such as [ErrorTypeInvalidInput]) to the caller.
|
||||
func ClassifyError(err error) string {
|
||||
switch {
|
||||
case err == nil:
|
||||
return ""
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return ErrorTypeTimeout
|
||||
case errors.Is(err, context.Canceled):
|
||||
return ErrorTypeContextCancelled
|
||||
case errors.Is(err, ErrMaximumQueueSizeExceeded):
|
||||
return ErrorTypeQueueSizeExceeded
|
||||
case errors.Is(err, ErrProcessAlreadyRestarting):
|
||||
return ErrorTypeProcessRestarting
|
||||
default:
|
||||
return ErrorTypeUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// SpanErrorType records errorType as the semconv error.type attribute on span.
|
||||
// It is a no-op when errorType is empty.
|
||||
func SpanErrorType(span trace.Span, errorType string) {
|
||||
if errorType == "" {
|
||||
return
|
||||
}
|
||||
span.SetAttributes(semconv.ErrorTypeKey.String(errorType))
|
||||
}
|
||||
60
pkg/gotenberg/errortype_test.go
Normal file
60
pkg/gotenberg/errortype_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
)
|
||||
|
||||
func TestClassifyError(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{"nil", nil, ""},
|
||||
{"deadline", context.DeadlineExceeded, ErrorTypeTimeout},
|
||||
{"canceled", context.Canceled, ErrorTypeContextCancelled},
|
||||
{"queue size exceeded", ErrMaximumQueueSizeExceeded, ErrorTypeQueueSizeExceeded},
|
||||
{"process restarting", ErrProcessAlreadyRestarting, ErrorTypeProcessRestarting},
|
||||
{"wrapped deadline", fmt.Errorf("convert: %w", context.DeadlineExceeded), ErrorTypeTimeout},
|
||||
{"joined queue", errors.Join(errors.New("attempt"), ErrMaximumQueueSizeExceeded), ErrorTypeQueueSizeExceeded},
|
||||
{"arbitrary", errors.New("boom"), ErrorTypeUnknown},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := ClassifyError(tc.err); got != tc.want {
|
||||
t.Errorf("ClassifyError(%v) = %q, want %q", tc.err, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpanErrorType(t *testing.T) {
|
||||
recorder := newTestSpanRecorder(t)
|
||||
|
||||
_, span := otel.Tracer("test").Start(context.Background(), "engine.Op")
|
||||
SpanErrorType(span, "") // no-op, must not add an attribute
|
||||
SpanErrorType(span, ErrorTypeTimeout) // sets error.type
|
||||
span.End()
|
||||
|
||||
got := findSpan(recorder, "engine.Op")
|
||||
if got == nil {
|
||||
t.Fatal("expected the span to be recorded")
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, kv := range got.Attributes() {
|
||||
if string(kv.Key) == "error.type" {
|
||||
count++
|
||||
if kv.Value.AsString() != ErrorTypeTimeout {
|
||||
t.Errorf("expected error.type=%q, got %q", ErrorTypeTimeout, kv.Value.AsString())
|
||||
}
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected exactly one error.type attribute, got %d", count)
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,12 @@ func (h traceContextHandler) WithGroup(name string) slog.Handler {
|
||||
}
|
||||
|
||||
// NewStdHandler returns a [slog.Handler] instance for the standard output.
|
||||
func NewStdHandler(level slog.Level, format string, fieldsPrefix string, enableGcpFields bool) (slog.Handler, error) {
|
||||
// upperLevelCase is the value of the level-case setting that keeps the level
|
||||
// field uppercase in the standard output. It mirrors gotenberg.UpperLevelCase,
|
||||
// duplicated here because the internal log package cannot import gotenberg.
|
||||
const upperLevelCase = "upper"
|
||||
|
||||
func NewStdHandler(level slog.Level, format string, fieldsPrefix string, enableGcpFields bool, levelCase string) (slog.Handler, error) {
|
||||
// #nosec: G115
|
||||
isTerminal := term.IsTerminal(int(os.Stdout.Fd()))
|
||||
|
||||
@@ -82,7 +87,11 @@ func NewStdHandler(level slog.Level, format string, fieldsPrefix string, enableG
|
||||
a.Key = "severity"
|
||||
a.Value = slog.StringValue(gcpSeverity(l))
|
||||
default:
|
||||
a.Value = slog.StringValue(strings.ToLower(l.String()))
|
||||
if levelCase == upperLevelCase {
|
||||
a.Value = slog.StringValue(l.String())
|
||||
} else {
|
||||
a.Value = slog.StringValue(strings.ToLower(l.String()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
53
pkg/gotenberg/internal/log/stdhandler_test.go
Normal file
53
pkg/gotenberg/internal/log/stdhandler_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewStdHandler_LevelCase(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
levelCase string
|
||||
want string
|
||||
}{
|
||||
{"lower is the default behavior", "lower", "info"},
|
||||
{"upper keeps slog casing", "upper", "INFO"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
original := os.Stderr
|
||||
reader, writer, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("create pipe: %v", err)
|
||||
}
|
||||
os.Stderr = writer
|
||||
defer func() { os.Stderr = original }()
|
||||
|
||||
handler, err := NewStdHandler(slog.LevelInfo, "json", "", false, tc.levelCase)
|
||||
if err != nil {
|
||||
t.Fatalf("create handler: %v", err)
|
||||
}
|
||||
|
||||
slog.New(handler).Info("hello")
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close writer: %v", err)
|
||||
}
|
||||
out, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("read output: %v", err)
|
||||
}
|
||||
|
||||
var record map[string]any
|
||||
if err := json.Unmarshal(out, &record); err != nil {
|
||||
t.Fatalf("parse log line %q: %v", out, err)
|
||||
}
|
||||
if record["level"] != tc.want {
|
||||
t.Errorf("level = %v, want %v", record["level"], tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,22 @@
|
||||
//
|
||||
// Significantly inspired by https://github.com/lucavallin/gotel.
|
||||
//
|
||||
// # Sampling
|
||||
//
|
||||
// No sampler is configured in code, so the SDK default applies:
|
||||
// parentbased_always_on. Every trace is recorded and exported. Operators tune
|
||||
// sampling through the standard environment variables, honored by the SDK:
|
||||
//
|
||||
// OTEL_TRACES_SAMPLER e.g. parentbased_traceidratio, always_off
|
||||
// OTEL_TRACES_SAMPLER_ARG e.g. 0.1 for a 10% ratio
|
||||
//
|
||||
// Head sampling drops whole traces up front, including the rare slow or failed
|
||||
// conversions that matter most for diagnosis. For high-throughput deployments,
|
||||
// prefer keeping head sampling permissive and applying tail sampling in the
|
||||
// collector (sample on error or high latency), which decides after a trace
|
||||
// completes. Gotenberg emits trace-based metric exemplars, so the conversion
|
||||
// histograms still link to representative traces regardless of the head
|
||||
// sampling ratio.
|
||||
//
|
||||
// See https://opentelemetry.io/.
|
||||
package otel
|
||||
|
||||
@@ -14,33 +14,63 @@ import (
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/sdk/log"
|
||||
"go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/metric/exemplar"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
"go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
|
||||
)
|
||||
|
||||
// buildResource assembles the OpenTelemetry resource shared by the tracer,
|
||||
// meter, and logger providers. Detection is best-effort: a detector or merge
|
||||
// failure is logged and the build proceeds with whatever was gathered, so a
|
||||
// flaky environment never prevents telemetry from starting.
|
||||
func buildResource(ctx context.Context, logger *slog.Logger, serviceName, serviceVersion string) *resource.Resource {
|
||||
base := resource.NewWithAttributes(
|
||||
semconv.SchemaURL,
|
||||
semconv.ServiceName(serviceName),
|
||||
semconv.ServiceVersion(serviceVersion),
|
||||
)
|
||||
|
||||
// Granular detectors only. The WithProcess() bundle is deliberately omitted
|
||||
// because it adds process.command_args/process.command_line, which can carry
|
||||
// proxy credentials and host-resolver rules passed on the command line.
|
||||
detected, err := resource.New(ctx,
|
||||
resource.WithFromEnv(),
|
||||
resource.WithTelemetrySDK(),
|
||||
resource.WithHost(),
|
||||
resource.WithHostID(),
|
||||
resource.WithOS(),
|
||||
resource.WithContainer(),
|
||||
resource.WithProcessPID(),
|
||||
resource.WithProcessExecutableName(),
|
||||
resource.WithProcessExecutablePath(),
|
||||
resource.WithProcessRuntimeName(),
|
||||
resource.WithProcessRuntimeVersion(),
|
||||
resource.WithProcessRuntimeDescription(),
|
||||
)
|
||||
if err != nil {
|
||||
logger.WarnContext(ctx, fmt.Sprintf("partially detect OpenTelemetry resource: %s", err))
|
||||
}
|
||||
if detected == nil {
|
||||
return base
|
||||
}
|
||||
|
||||
merged, err := resource.Merge(detected, base)
|
||||
if err != nil {
|
||||
logger.WarnContext(ctx, fmt.Sprintf("merge OpenTelemetry resource: %s", err))
|
||||
return base
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// InitTracerProvider initializes the OpenTelemetry tracer provider.
|
||||
func InitTracerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, err error) {
|
||||
initOtelLogger(logger)
|
||||
|
||||
ctx := context.Background()
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get hostname: %w", err)
|
||||
}
|
||||
|
||||
res, err := resource.Merge(
|
||||
resource.Default(),
|
||||
resource.NewWithAttributes(
|
||||
semconv.SchemaURL,
|
||||
semconv.ServiceName(serviceName),
|
||||
semconv.ServiceVersion(serviceVersion),
|
||||
semconv.HostName(hostname),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("merge resource: %w", err)
|
||||
}
|
||||
res := buildResource(ctx, logger, serviceName, serviceVersion)
|
||||
|
||||
traceOpts := []trace.TracerProviderOption{
|
||||
trace.WithResource(res),
|
||||
@@ -71,27 +101,13 @@ func InitMeterProvider(logger *slog.Logger, serviceName, serviceVersion string)
|
||||
initOtelLogger(logger)
|
||||
|
||||
ctx := context.Background()
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get hostname: %w", err)
|
||||
}
|
||||
|
||||
res, err := resource.Merge(
|
||||
resource.Default(),
|
||||
resource.NewWithAttributes(
|
||||
semconv.SchemaURL,
|
||||
semconv.ServiceName(serviceName),
|
||||
semconv.ServiceVersion(serviceVersion),
|
||||
semconv.HostName(hostname),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("merge resource: %w", err)
|
||||
}
|
||||
res := buildResource(ctx, logger, serviceName, serviceVersion)
|
||||
|
||||
metricOpts := []metric.Option{
|
||||
metric.WithResource(res),
|
||||
}
|
||||
metricOpts = append(metricOpts, exemplarFilterOptions()...)
|
||||
|
||||
metricReader, err := autoexport.NewMetricReader(ctx)
|
||||
if err != nil {
|
||||
@@ -108,28 +124,24 @@ func InitMeterProvider(logger *slog.Logger, serviceName, serviceVersion string)
|
||||
return meterProvider.Shutdown, nil
|
||||
}
|
||||
|
||||
// exemplarFilterOptions returns the meter provider options that pin trace-based
|
||||
// exemplars, so the histograms expose the trace id of a representative
|
||||
// measurement. It yields no option when the operator selects a filter via
|
||||
// OTEL_METRICS_EXEMPLAR_FILTER, letting the SDK's own env handling win.
|
||||
func exemplarFilterOptions() []metric.Option {
|
||||
if _, ok := os.LookupEnv("OTEL_METRICS_EXEMPLAR_FILTER"); ok {
|
||||
return nil
|
||||
}
|
||||
return []metric.Option{metric.WithExemplarFilter(exemplar.TraceBasedFilter)}
|
||||
}
|
||||
|
||||
// InitLoggerProvider initializes the OpenTelemetry logger provider.
|
||||
func InitLoggerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, handler slog.Handler, err error) {
|
||||
initOtelLogger(logger)
|
||||
|
||||
ctx := context.Background()
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("get hostname: %w", err)
|
||||
}
|
||||
|
||||
res, err := resource.Merge(
|
||||
resource.Default(),
|
||||
resource.NewWithAttributes(
|
||||
semconv.SchemaURL,
|
||||
semconv.ServiceName(serviceName),
|
||||
semconv.ServiceVersion(serviceVersion),
|
||||
semconv.HostName(hostname),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("merge resource: %w", err)
|
||||
}
|
||||
res := buildResource(ctx, logger, serviceName, serviceVersion)
|
||||
|
||||
logOpts := []log.LoggerProviderOption{
|
||||
log.WithResource(res),
|
||||
|
||||
151
pkg/gotenberg/internal/otel/otel_test.go
Normal file
151
pkg/gotenberg/internal/otel/otel_test.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package otel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/metric/exemplar"
|
||||
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
|
||||
)
|
||||
|
||||
func TestBuildResource(t *testing.T) {
|
||||
res := buildResource(context.Background(), slog.New(slog.DiscardHandler), "gotenberg", "v8.0.0")
|
||||
|
||||
got := map[string]struct{}{}
|
||||
values := map[string]string{}
|
||||
for _, kv := range res.Attributes() {
|
||||
got[string(kv.Key)] = struct{}{}
|
||||
values[string(kv.Key)] = kv.Value.AsString()
|
||||
}
|
||||
|
||||
if values[string(semconv.ServiceNameKey)] != "gotenberg" {
|
||||
t.Errorf("service.name = %q, want %q", values[string(semconv.ServiceNameKey)], "gotenberg")
|
||||
}
|
||||
if values[string(semconv.ServiceVersionKey)] != "v8.0.0" {
|
||||
t.Errorf("service.version = %q, want %q", values[string(semconv.ServiceVersionKey)], "v8.0.0")
|
||||
}
|
||||
|
||||
for _, key := range []string{
|
||||
string(semconv.HostNameKey),
|
||||
string(semconv.OSTypeKey),
|
||||
string(semconv.ProcessRuntimeNameKey),
|
||||
} {
|
||||
if _, ok := got[key]; !ok {
|
||||
t.Errorf("expected resource attribute %q to be present", key)
|
||||
}
|
||||
}
|
||||
|
||||
// The command-line bundle must never be detected (it can leak credentials).
|
||||
for _, key := range []string{"process.command_args", "process.command_line"} {
|
||||
if _, ok := got[key]; ok {
|
||||
t.Errorf("did not expect sensitive resource attribute %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestInitTracerProvider_HonorsSamplerEnv guards the contract that the tracer
|
||||
// provider keeps honoring OTEL_TRACES_SAMPLER. The SDK reads it only when no
|
||||
// explicit sampler is configured, so any future WithSampler() would silently
|
||||
// break operator-side sampling control.
|
||||
func TestInitTracerProvider_HonorsSamplerEnv(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
sampler string
|
||||
wantSampled bool
|
||||
}{
|
||||
{"always off", "always_off", false},
|
||||
{"always on", "always_on", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("OTEL_TRACES_EXPORTER", "none")
|
||||
t.Setenv("OTEL_TRACES_SAMPLER", tc.sampler)
|
||||
|
||||
shutdown, err := InitTracerProvider(slog.New(slog.DiscardHandler), "test", "v0.0.0")
|
||||
if err != nil {
|
||||
t.Fatalf("init tracer provider: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = shutdown(context.Background()) })
|
||||
|
||||
_, span := otel.Tracer("test").Start(context.Background(), "span")
|
||||
span.End()
|
||||
|
||||
if got := span.SpanContext().IsSampled(); got != tc.wantSampled {
|
||||
t.Errorf("OTEL_TRACES_SAMPLER=%q: IsSampled() = %v, want %v", tc.sampler, got, tc.wantSampled)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExemplarFilterOptions(t *testing.T) {
|
||||
t.Run("default pins trace-based", func(t *testing.T) {
|
||||
if v, ok := os.LookupEnv("OTEL_METRICS_EXEMPLAR_FILTER"); ok {
|
||||
os.Unsetenv("OTEL_METRICS_EXEMPLAR_FILTER")
|
||||
t.Cleanup(func() { os.Setenv("OTEL_METRICS_EXEMPLAR_FILTER", v) })
|
||||
}
|
||||
if got := exemplarFilterOptions(); len(got) != 1 {
|
||||
t.Errorf("expected 1 option when env unset, got %d", len(got))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("env override yields no option", func(t *testing.T) {
|
||||
t.Setenv("OTEL_METRICS_EXEMPLAR_FILTER", "always_off")
|
||||
if got := exemplarFilterOptions(); len(got) != 0 {
|
||||
t.Errorf("expected 0 options when env set, got %d", len(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestMeterProvider_TraceBasedExemplar guards that the trace-based filter we pin
|
||||
// actually attaches a trace id to a histogram measurement recorded inside a
|
||||
// sampled span.
|
||||
func TestMeterProvider_TraceBasedExemplar(t *testing.T) {
|
||||
reader := sdkmetric.NewManualReader()
|
||||
provider := sdkmetric.NewMeterProvider(
|
||||
sdkmetric.WithReader(reader),
|
||||
sdkmetric.WithExemplarFilter(exemplar.TraceBasedFilter),
|
||||
)
|
||||
t.Cleanup(func() { _ = provider.Shutdown(context.Background()) })
|
||||
|
||||
hist, err := provider.Meter("test").Float64Histogram("conversion.duration")
|
||||
if err != nil {
|
||||
t.Fatalf("create histogram: %v", err)
|
||||
}
|
||||
|
||||
tracer := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.AlwaysSample())).Tracer("test")
|
||||
ctx, span := tracer.Start(context.Background(), "conversion")
|
||||
hist.Record(ctx, 1.0)
|
||||
traceID := span.SpanContext().TraceID()
|
||||
span.End()
|
||||
|
||||
var rm metricdata.ResourceMetrics
|
||||
if err := reader.Collect(context.Background(), &rm); err != nil {
|
||||
t.Fatalf("collect: %v", err)
|
||||
}
|
||||
|
||||
var found bool
|
||||
for _, sm := range rm.ScopeMetrics {
|
||||
for _, m := range sm.Metrics {
|
||||
hd, ok := m.Data.(metricdata.Histogram[float64])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, dp := range hd.DataPoints {
|
||||
for _, ex := range dp.Exemplars {
|
||||
if string(ex.TraceID) == string(traceID[:]) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Error("expected a trace-based exemplar carrying the span trace id")
|
||||
}
|
||||
}
|
||||
@@ -45,21 +45,23 @@ func (mod *DebuggableMock) Debug() map[string]any {
|
||||
//
|
||||
//nolint:dupl
|
||||
type PdfEngineMock struct {
|
||||
MergeMock func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
|
||||
SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
|
||||
FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath string) error
|
||||
ConvertMock func(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
|
||||
ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
|
||||
PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
|
||||
WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
|
||||
ReadBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error)
|
||||
EncryptMock func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
|
||||
EmbedFilesMock func(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
|
||||
EmbedFilesMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error
|
||||
WriteBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
|
||||
WatermarkMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
|
||||
StampMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
|
||||
RotateMock func(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
|
||||
MergeMock func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
|
||||
SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
|
||||
FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath string) error
|
||||
ConvertMock func(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
|
||||
ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
|
||||
PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
|
||||
WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
|
||||
ReadBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error)
|
||||
EncryptMock func(ctx context.Context, logger *slog.Logger, inputPath string, opts EncryptOptions) error
|
||||
EmbedFilesMock func(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
|
||||
EmbedFilesMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error
|
||||
WriteBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
|
||||
WatermarkMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
|
||||
StampMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
|
||||
RotateMock func(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
|
||||
InjectFacturXXMPMock func(ctx context.Context, logger *slog.Logger, facturX FacturX, inputPath string) error
|
||||
ReadPdfAConformanceMock func(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
||||
@@ -94,8 +96,8 @@ func (engine *PdfEngineMock) ReadBookmarks(ctx context.Context, logger *slog.Log
|
||||
return engine.ReadBookmarksMock(ctx, logger, inputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
|
||||
return engine.EncryptMock(ctx, logger, inputPath, userPassword, ownerPassword)
|
||||
func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts EncryptOptions) error {
|
||||
return engine.EncryptMock(ctx, logger, inputPath, opts)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
||||
@@ -122,6 +124,14 @@ func (engine *PdfEngineMock) Rotate(ctx context.Context, logger *slog.Logger, in
|
||||
return engine.RotateMock(ctx, logger, inputPath, angle, pages)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX FacturX, inputPath string) error {
|
||||
return engine.InjectFacturXXMPMock(ctx, logger, facturX, inputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
|
||||
return engine.ReadPdfAConformanceMock(ctx, logger, inputPath)
|
||||
}
|
||||
|
||||
// PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface.
|
||||
type PdfEngineProviderMock struct {
|
||||
PdfEngineMock func() (PdfEngine, error)
|
||||
@@ -152,13 +162,14 @@ func (p *ProcessMock) Healthy(logger *slog.Logger) bool {
|
||||
|
||||
// ProcessSupervisorMock is a mock for the [ProcessSupervisor] interface.
|
||||
type ProcessSupervisorMock struct {
|
||||
LaunchMock func() error
|
||||
ShutdownMock func() error
|
||||
HealthyMock func() bool
|
||||
RunMock func(ctx context.Context, logger *slog.Logger, task func() error) error
|
||||
ReqQueueSizeMock func() int64
|
||||
RestartsCountMock func() int64
|
||||
ActiveTasksCountMock func() int64
|
||||
LaunchMock func() error
|
||||
ShutdownMock func() error
|
||||
HealthyMock func() bool
|
||||
RunMock func(ctx context.Context, logger *slog.Logger, task func() error) error
|
||||
ReqQueueSizeMock func() int64
|
||||
RestartsCountMock func() int64
|
||||
ActiveTasksCountMock func() int64
|
||||
ConversionsSinceRestartMock func() int64
|
||||
}
|
||||
|
||||
func (s *ProcessSupervisorMock) Launch() error {
|
||||
@@ -189,6 +200,10 @@ func (s *ProcessSupervisorMock) ActiveTasksCount() int64 {
|
||||
return s.ActiveTasksCountMock()
|
||||
}
|
||||
|
||||
func (s *ProcessSupervisorMock) ConversionsSinceRestart() int64 {
|
||||
return s.ConversionsSinceRestartMock()
|
||||
}
|
||||
|
||||
// MetricsProviderMock is a mock for the [MetricsProvider] interface.
|
||||
type MetricsProviderMock struct {
|
||||
MetricsMock func() ([]Metric, error)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -12,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"golang.org/x/net/http/httpproxy"
|
||||
)
|
||||
|
||||
// ErrNonPublicIP indicates that an outbound URL targets an IP address that
|
||||
@@ -389,9 +393,37 @@ func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, er
|
||||
//
|
||||
// The default posture is permissive; callers pass [WithDenyPrivateIPs]
|
||||
// or [WithDenyPublicIPs] to opt into IP-class rejection.
|
||||
func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2.Regexp, opts ...DecideOption) *http.Client {
|
||||
//
|
||||
// When enableEnvironmentProxy is true, the client routes through the proxy
|
||||
// defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables,
|
||||
// including any credentials embedded in those URLs. In that mode the proxy
|
||||
// owns DNS and egress, so destination dial pinning does not apply; the URL
|
||||
// allow/deny and IP-class validation still runs. Callers gate this behind
|
||||
// their module's opt-in flag. See
|
||||
// https://github.com/gotenberg/gotenberg/issues/1592.
|
||||
func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2.Regexp, enableEnvironmentProxy bool, opts ...DecideOption) *http.Client {
|
||||
base := http.DefaultTransport.(*http.Transport).Clone()
|
||||
base.DialContext = secureDialContext
|
||||
|
||||
if enableEnvironmentProxy {
|
||||
// Route through the operator's proxy (standard env vars, credentials
|
||||
// included). NO_PROXY hosts get a direct, unpinned dial.
|
||||
// httpproxy.FromEnvironment reads the environment now rather than
|
||||
// caching it process-wide like http.ProxyFromEnvironment.
|
||||
proxyFunc := httpproxy.FromEnvironment().ProxyFunc()
|
||||
base.Proxy = func(req *http.Request) (*url.URL, error) {
|
||||
return proxyFunc(req.URL)
|
||||
}
|
||||
base.DialContext = outboundDialer.DialContext
|
||||
} else {
|
||||
// Default: ignore any proxy environment variables and pin the dial to
|
||||
// the IPs resolved during validation, closing the DNS-rebinding
|
||||
// window. Clearing Proxy is deliberate: the cloned default transport
|
||||
// carries http.ProxyFromEnvironment, which combined with the pinned
|
||||
// dialer would connect to the destination IP on the proxy's port.
|
||||
base.Proxy = nil
|
||||
base.DialContext = secureDialContext
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &outboundRoundTripper{
|
||||
@@ -452,3 +484,99 @@ func DialPinned(ctx context.Context, network string, addrs []netip.Addr, port st
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// DialThroughProxy opens a TCP tunnel to target (a host:port) through the
|
||||
// HTTP CONNECT proxy at proxyURL, authenticating with any credentials
|
||||
// embedded in proxyURL. dialProxy dials the proxy's own address; callers pass
|
||||
// a plain dialer. Chromium and soffice cannot authenticate to a proxy
|
||||
// themselves, so Gotenberg performs the CONNECT handshake on their behalf.
|
||||
// The returned connection carries the raw tunnel for the caller to splice
|
||||
// with the client. See https://github.com/gotenberg/gotenberg/issues/1592.
|
||||
func DialThroughProxy(ctx context.Context, proxyURL *url.URL, target string, dialProxy func(ctx context.Context, network, addr string) (net.Conn, error)) (net.Conn, error) {
|
||||
conn, err := dialProxy(ctx, "tcp", proxyHostPort(proxyURL))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial proxy: %w", err)
|
||||
}
|
||||
|
||||
if proxyURL.Scheme == "https" {
|
||||
tlsConn := tls.Client(conn, &tls.Config{ServerName: proxyURL.Hostname()})
|
||||
err = tlsConn.HandshakeContext(ctx)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("TLS handshake with proxy: %w", err)
|
||||
}
|
||||
conn = tlsConn
|
||||
}
|
||||
|
||||
// Bound the CONNECT handshake by the request deadline; cleared once the
|
||||
// tunnel is established so splicing manages its own lifetime.
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(deadline)
|
||||
}
|
||||
|
||||
connectReq := &http.Request{
|
||||
Method: http.MethodConnect,
|
||||
URL: &url.URL{Opaque: target},
|
||||
Host: target,
|
||||
Header: make(http.Header),
|
||||
}
|
||||
if user := proxyURL.User; user != nil {
|
||||
password, _ := user.Password()
|
||||
connectReq.Header.Set("Proxy-Authorization", proxyAuthHeader(user.Username(), password))
|
||||
}
|
||||
|
||||
err = connectReq.Write(conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("write CONNECT to proxy: %w", err)
|
||||
}
|
||||
|
||||
br := bufio.NewReader(conn)
|
||||
resp, err := http.ReadResponse(br, connectReq)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("read CONNECT response from proxy: %w", err)
|
||||
}
|
||||
// A CONNECT response carries no body; discard defensively.
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("proxy refused CONNECT to %q with status %d", target, resp.StatusCode)
|
||||
}
|
||||
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
|
||||
// The reader may hold bytes the proxy sent right after the response;
|
||||
// overlay it so those tunnel bytes are not lost when splicing.
|
||||
return &bufferedConn{Conn: conn, r: br}, nil
|
||||
}
|
||||
|
||||
// proxyHostPort returns proxyURL's host:port, defaulting the port from the
|
||||
// scheme when the URL omits it.
|
||||
func proxyHostPort(proxyURL *url.URL) string {
|
||||
port := proxyURL.Port()
|
||||
if port == "" {
|
||||
port = "80"
|
||||
if proxyURL.Scheme == "https" {
|
||||
port = "443"
|
||||
}
|
||||
}
|
||||
return net.JoinHostPort(proxyURL.Hostname(), port)
|
||||
}
|
||||
|
||||
// proxyAuthHeader builds a Basic Proxy-Authorization header value.
|
||||
func proxyAuthHeader(username, password string) string {
|
||||
return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))
|
||||
}
|
||||
|
||||
// bufferedConn overlays a [bufio.Reader] on a [net.Conn] so that bytes
|
||||
// buffered while reading a proxy's CONNECT response are not lost when the
|
||||
// tunnel is spliced.
|
||||
type bufferedConn struct {
|
||||
net.Conn
|
||||
r *bufio.Reader
|
||||
}
|
||||
|
||||
func (c *bufferedConn) Read(b []byte) (int, error) {
|
||||
return c.r.Read(b)
|
||||
}
|
||||
|
||||
153
pkg/gotenberg/outbound_proxy_test.go
Normal file
153
pkg/gotenberg/outbound_proxy_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// connectCapture records the CONNECT request a proxy stub received.
|
||||
type connectCapture struct {
|
||||
mu sync.Mutex
|
||||
method string
|
||||
host string
|
||||
auth string
|
||||
}
|
||||
|
||||
func (c *connectCapture) set(method, host, auth string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.method, c.host, c.auth = method, host, auth
|
||||
}
|
||||
|
||||
func (c *connectCapture) get() (string, string, string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.method, c.host, c.auth
|
||||
}
|
||||
|
||||
// startConnectProxyStub starts a raw TCP server that behaves like an HTTP
|
||||
// CONNECT proxy: it reads the CONNECT request, records it, replies 200 with a
|
||||
// greeting appended to the same write (to exercise buffered-byte handling),
|
||||
// then echoes tunnel bytes back to the caller.
|
||||
func startConnectProxyStub(t *testing.T, capture *connectCapture) string {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = l.Close() })
|
||||
|
||||
go func() {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
br := bufio.NewReader(conn)
|
||||
req, err := http.ReadRequest(br)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
capture.set(req.Method, req.Host, req.Header.Get("Proxy-Authorization"))
|
||||
|
||||
// The greeting rides along with the response so the client's CONNECT
|
||||
// response parser buffers it; bufferedConn must not drop it.
|
||||
_, _ = conn.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\nTUNNEL-HELLO"))
|
||||
_, _ = io.Copy(conn, br)
|
||||
}()
|
||||
|
||||
return l.Addr().String()
|
||||
}
|
||||
|
||||
func TestDialThroughProxy(t *testing.T) {
|
||||
capture := &connectCapture{}
|
||||
addr := startConnectProxyStub(t, capture)
|
||||
|
||||
proxyURL := &url.URL{Scheme: "http", Host: addr, User: url.UserPassword("alice", "s3cr3t")}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
conn, err := DialThroughProxy(ctx, proxyURL, "example.com:443", func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, network, addr)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DialThroughProxy: %v", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
// The greeting buffered while reading the CONNECT response must survive.
|
||||
greeting := make([]byte, len("TUNNEL-HELLO"))
|
||||
_, err = io.ReadFull(conn, greeting)
|
||||
if err != nil {
|
||||
t.Fatalf("read greeting: %v", err)
|
||||
}
|
||||
if string(greeting) != "TUNNEL-HELLO" {
|
||||
t.Fatalf("greeting = %q, want TUNNEL-HELLO", greeting)
|
||||
}
|
||||
|
||||
// The tunnel must round-trip bytes.
|
||||
_, err = conn.Write([]byte("ping"))
|
||||
if err != nil {
|
||||
t.Fatalf("write to tunnel: %v", err)
|
||||
}
|
||||
echo := make([]byte, 4)
|
||||
_, err = io.ReadFull(conn, echo)
|
||||
if err != nil {
|
||||
t.Fatalf("read echo: %v", err)
|
||||
}
|
||||
if string(echo) != "ping" {
|
||||
t.Fatalf("echo = %q, want ping", echo)
|
||||
}
|
||||
|
||||
method, host, auth := capture.get()
|
||||
if method != http.MethodConnect {
|
||||
t.Fatalf("proxy saw method %q, want CONNECT", method)
|
||||
}
|
||||
if host != "example.com:443" {
|
||||
t.Fatalf("proxy saw target %q, want example.com:443", host)
|
||||
}
|
||||
wantAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("alice:s3cr3t"))
|
||||
if auth != wantAuth {
|
||||
t.Fatalf("proxy saw Proxy-Authorization %q, want %q", auth, wantAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialThroughProxy_RefusedStatus(t *testing.T) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = l.Close() })
|
||||
|
||||
go func() {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
br := bufio.NewReader(conn)
|
||||
_, _ = http.ReadRequest(br)
|
||||
_, _ = conn.Write([]byte("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n"))
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err = DialThroughProxy(ctx, &url.URL{Scheme: "http", Host: l.Addr().String()}, "example.com:443", func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, network, addr)
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when the proxy refuses CONNECT, got nil")
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,10 @@ var (
|
||||
// ErrPdfRotateAngleNotSupported is returned when the rotation angle is
|
||||
// not supported.
|
||||
ErrPdfRotateAngleNotSupported = errors.New("rotation angle not supported")
|
||||
|
||||
// ErrPdfFacturXValueNotSupported is returned when a Factur-X field value
|
||||
// (e.g., an unknown conformance level or document type) is not supported.
|
||||
ErrPdfFacturXValueNotSupported = errors.New("Factur-X value not supported")
|
||||
)
|
||||
|
||||
// PdfEngineInvalidArgsError represents an error returned by a PDF engine when
|
||||
@@ -143,6 +147,56 @@ type PdfFormats struct {
|
||||
PdfUa bool
|
||||
}
|
||||
|
||||
// PdfPermissions gathers the document permissions enforced when a PDF is
|
||||
// encrypted. Each field defaults to true (the action is allowed); set a field
|
||||
// to false to restrict it. Restrictions are advisory: viewers honor them, but
|
||||
// they are not cryptographically enforced once the document opens.
|
||||
type PdfPermissions struct {
|
||||
// AllowPrinting permits printing the document.
|
||||
AllowPrinting bool
|
||||
|
||||
// AllowCopying permits extracting text and graphics.
|
||||
AllowCopying bool
|
||||
|
||||
// AllowModifying permits changing the document content.
|
||||
AllowModifying bool
|
||||
|
||||
// AllowAnnotating permits adding or modifying annotations.
|
||||
AllowAnnotating bool
|
||||
|
||||
// AllowFillingForms permits filling in form fields.
|
||||
AllowFillingForms bool
|
||||
|
||||
// AllowAssembling permits inserting, deleting, and rotating pages.
|
||||
AllowAssembling bool
|
||||
}
|
||||
|
||||
// Restricted reports whether at least one permission is denied.
|
||||
func (p PdfPermissions) Restricted() bool {
|
||||
return !p.AllowPrinting ||
|
||||
!p.AllowCopying ||
|
||||
!p.AllowModifying ||
|
||||
!p.AllowAnnotating ||
|
||||
!p.AllowFillingForms ||
|
||||
!p.AllowAssembling
|
||||
}
|
||||
|
||||
// EncryptOptions gathers the parameters for encrypting a PDF. An empty
|
||||
// UserPassword with a set OwnerPassword produces an owner-only document: it
|
||||
// opens without a password but enforces the [PdfPermissions].
|
||||
type EncryptOptions struct {
|
||||
// UserPassword is required to open the document. Empty means no open
|
||||
// password.
|
||||
UserPassword string
|
||||
|
||||
// OwnerPassword grants full access (lifts the permission restrictions).
|
||||
// When empty, it defaults to UserPassword.
|
||||
OwnerPassword string
|
||||
|
||||
// Permissions are the actions allowed when opened with the user password.
|
||||
Permissions PdfPermissions
|
||||
}
|
||||
|
||||
// Bookmark represents a node in the PDF document's outline
|
||||
// (table of contents).
|
||||
type Bookmark struct {
|
||||
@@ -151,6 +205,59 @@ type Bookmark struct {
|
||||
Children []Bookmark `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
// FacturXConformanceMinimum represents the MINIMUM Factur-X conformance level.
|
||||
FacturXConformanceMinimum string = "MINIMUM"
|
||||
|
||||
// FacturXConformanceBasicWL represents the BASIC WL Factur-X conformance level.
|
||||
FacturXConformanceBasicWL string = "BASIC WL"
|
||||
|
||||
// FacturXConformanceBasic represents the BASIC Factur-X conformance level.
|
||||
FacturXConformanceBasic string = "BASIC"
|
||||
|
||||
// FacturXConformanceEN16931 represents the EN 16931 Factur-X conformance level.
|
||||
FacturXConformanceEN16931 string = "EN 16931"
|
||||
|
||||
// FacturXConformanceExtended represents the EXTENDED Factur-X conformance level.
|
||||
FacturXConformanceExtended string = "EXTENDED"
|
||||
|
||||
// FacturXConformanceXRechnung represents the XRECHNUNG Factur-X conformance level.
|
||||
FacturXConformanceXRechnung string = "XRECHNUNG"
|
||||
|
||||
// FacturXDocumentTypeInvoice represents the INVOICE Factur-X document type.
|
||||
FacturXDocumentTypeInvoice string = "INVOICE"
|
||||
|
||||
// FacturXDocumentTypeOrder represents the ORDER Factur-X document type.
|
||||
FacturXDocumentTypeOrder string = "ORDER"
|
||||
|
||||
// FacturXDocumentTypeOrderResponse represents the ORDER_RESPONSE Factur-X document type.
|
||||
FacturXDocumentTypeOrderResponse string = "ORDER_RESPONSE"
|
||||
|
||||
// FacturXDocumentTypeOrderChange represents the ORDER_CHANGE Factur-X document type.
|
||||
FacturXDocumentTypeOrderChange string = "ORDER_CHANGE"
|
||||
|
||||
// FacturXDocumentFileName is the canonical name of the embedded XML invoice
|
||||
// mandated by the Factur-X standard. Validators expect this exact name.
|
||||
FacturXDocumentFileName string = "factur-x.xml"
|
||||
)
|
||||
|
||||
// FacturX gathers the properties required by the Factur-X/ZUGFeRD standard for
|
||||
// the document-level XMP metadata packet of a PDF/A-3.
|
||||
type FacturX struct {
|
||||
// ConformanceLevel is one of the FacturXConformance* values.
|
||||
ConformanceLevel string
|
||||
|
||||
// DocumentType is one of the FacturXDocumentType* values.
|
||||
DocumentType string
|
||||
|
||||
// DocumentFileName is the name of the embedded XML invoice. It is set
|
||||
// internally to the canonical [FacturXDocumentFileName], not by the caller.
|
||||
DocumentFileName string
|
||||
|
||||
// Version is the Factur-X version (e.g., "1.0").
|
||||
Version string
|
||||
}
|
||||
|
||||
// PdfEngine provides an interface for operations on PDFs. Implementations
|
||||
// can use various tools like PDFtk, or implement functionality directly in
|
||||
// Go.
|
||||
@@ -190,11 +297,11 @@ type PdfEngine interface {
|
||||
// The bookmarks parameter represents the hierarchical tree of the outline.
|
||||
WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
|
||||
|
||||
// Encrypt adds password protection to a PDF file.
|
||||
// The userPassword is required to open the document.
|
||||
// The ownerPassword provides full access to the document.
|
||||
// If the ownerPassword is empty, it defaults to the userPassword.
|
||||
Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
|
||||
// Encrypt adds password protection and permission restrictions to a PDF
|
||||
// file, as described by [EncryptOptions]. An empty user password with a set
|
||||
// owner password yields an owner-only document (opens without a password,
|
||||
// permissions enforced).
|
||||
Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts EncryptOptions) error
|
||||
|
||||
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
|
||||
// without modifying the main PDF content.
|
||||
@@ -216,6 +323,18 @@ type PdfEngine interface {
|
||||
// Rotate rotates pages of a PDF file by the given angle (90, 180, 270).
|
||||
// If pages is empty, all pages are rotated.
|
||||
Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
|
||||
|
||||
// InjectFacturXXMP injects Factur-X/ZUGFeRD XMP metadata into the
|
||||
// document-level XMP packet (Catalog /Metadata stream) of a PDF/A-3. It
|
||||
// registers the fx namespace, the four fx properties, and the matching
|
||||
// PDF/A extension schema so the result stays PDF/A-valid.
|
||||
InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX FacturX, inputPath string) error
|
||||
|
||||
// ReadPdfAConformance reads the PDF/A part and conformance (e.g., "3" and
|
||||
// "B") from the document-level XMP packet (Catalog /Metadata stream,
|
||||
// pdfaid:part and pdfaid:conformance). It returns empty strings when the
|
||||
// document carries no PDF/A identification.
|
||||
ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (part string, conformance string, err error)
|
||||
}
|
||||
|
||||
// PdfEngineProvider offers an interface to instantiate a [PdfEngine].
|
||||
|
||||
@@ -8,6 +8,10 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// ErrProcessAlreadyRestarting happens if the [ProcessSupervisor] is trying
|
||||
@@ -76,6 +80,10 @@ type ProcessSupervisor interface {
|
||||
|
||||
// ActiveTasksCount returns the current number of active tasks.
|
||||
ActiveTasksCount() int64
|
||||
|
||||
// ConversionsSinceRestart returns the number of tasks handled since the
|
||||
// last process (re)start.
|
||||
ConversionsSinceRestart() int64
|
||||
}
|
||||
|
||||
// healthCheckCacheTTL caches successful health probe results so kubelet-
|
||||
@@ -97,6 +105,7 @@ const healthFailureThreshold = 2
|
||||
|
||||
type processSupervisor struct {
|
||||
logger *slog.Logger
|
||||
engine string
|
||||
process Process
|
||||
maxReqLimit int64
|
||||
maxQueueSize int64
|
||||
@@ -128,14 +137,21 @@ type processSupervisor struct {
|
||||
idleStopChan chan struct{} // signal to stop the idle ticker goroutine
|
||||
}
|
||||
|
||||
// NewProcessSupervisor initializes a new [ProcessSupervisor].
|
||||
func NewProcessSupervisor(logger *slog.Logger, process Process, maxReqLimit, maxQueueSize, maxConcurrency int64, idleShutdownTimeout time.Duration) ProcessSupervisor {
|
||||
// NewProcessSupervisor initializes a new [ProcessSupervisor]. engine names the
|
||||
// managed process (for example "chromium" or "libreoffice") and prefixes the
|
||||
// telemetry sub-spans; an empty engine falls back to "process".
|
||||
func NewProcessSupervisor(logger *slog.Logger, engine string, process Process, maxReqLimit, maxQueueSize, maxConcurrency int64, idleShutdownTimeout time.Duration) ProcessSupervisor {
|
||||
if maxConcurrency < 1 {
|
||||
maxConcurrency = 1
|
||||
}
|
||||
|
||||
if engine == "" {
|
||||
engine = "process"
|
||||
}
|
||||
|
||||
b := &processSupervisor{
|
||||
logger: logger,
|
||||
engine: engine,
|
||||
process: process,
|
||||
semaphore: make(chan struct{}, maxConcurrency),
|
||||
maxReqLimit: maxReqLimit,
|
||||
@@ -267,6 +283,20 @@ func (s *processSupervisor) recentlyHealthy() bool {
|
||||
}
|
||||
|
||||
func (s *processSupervisor) Run(ctx context.Context, logger *slog.Logger, task func() error) error {
|
||||
// Time spent before the task body runs: queueing, slot acquisition, lazy
|
||||
// launch, and health checks. Ended once, when the task is about to execute.
|
||||
_, queueSpan := Tracer().Start(ctx, s.engine+".queue.wait",
|
||||
trace.WithSpanKind(trace.SpanKindInternal),
|
||||
)
|
||||
queueWaitDone := false
|
||||
endQueueWait := func() {
|
||||
if !queueWaitDone {
|
||||
queueWaitDone = true
|
||||
queueSpan.End()
|
||||
}
|
||||
}
|
||||
defer endQueueWait()
|
||||
|
||||
// Atomically check and increment the queue size to avoid the TOCTOU race
|
||||
// originally reported in https://github.com/gotenberg/gotenberg/issues/951.
|
||||
for {
|
||||
@@ -315,6 +345,7 @@ func (s *processSupervisor) Run(ctx context.Context, logger *slog.Logger, task f
|
||||
return err
|
||||
}
|
||||
|
||||
endQueueWait()
|
||||
err := s.runWithDeadline(ctx, task)
|
||||
|
||||
if s.maybeRestartAfterTask(logger) {
|
||||
@@ -453,8 +484,8 @@ func (s *processSupervisor) ensureStarted(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := s.runWithDeadline(ctx, func() error {
|
||||
return s.Launch()
|
||||
err := s.tracedLaunch(ctx, "first_start", func() error {
|
||||
return s.runWithDeadline(ctx, s.Launch)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("process first start: %w", err)
|
||||
@@ -463,6 +494,27 @@ func (s *processSupervisor) ensureStarted(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// tracedLaunch wraps a process (re)start in an <engine>.process.start span,
|
||||
// tagged with the reason that triggered it. The eager restart after the maximum
|
||||
// request limit runs on a background context, so its span is a detached root.
|
||||
func (s *processSupervisor) tracedLaunch(ctx context.Context, reason string, launch func() error) error {
|
||||
_, span := Tracer().Start(ctx, s.engine+".process.start",
|
||||
trace.WithSpanKind(trace.SpanKindInternal),
|
||||
trace.WithAttributes(attribute.String("gotenberg.process.start.reason", reason)),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
err := launch()
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureHealthy checks the underlying process health and triggers a
|
||||
// synchronous restart if the process is unhealthy. Skips the check if a
|
||||
// restart is already in progress.
|
||||
@@ -473,7 +525,7 @@ func (s *processSupervisor) ensureHealthy(ctx context.Context) error {
|
||||
|
||||
s.logger.DebugContext(context.Background(), "process is unhealthy, cannot handle task, restarting...")
|
||||
|
||||
if err := s.doRestart(ctx); err != nil {
|
||||
if err := s.doRestart(ctx, "unhealthy"); err != nil {
|
||||
return fmt.Errorf("process restart before task: %w", err)
|
||||
}
|
||||
|
||||
@@ -496,7 +548,7 @@ func (s *processSupervisor) maybeRestartAfterTask(logger *slog.Logger) bool {
|
||||
s.logger.DebugContext(context.Background(), "max request limit reached, restarting eagerly...")
|
||||
|
||||
go func() {
|
||||
restartErr := s.doRestartLocked(context.Background())
|
||||
restartErr := s.doRestartLocked(context.Background(), "max_requests")
|
||||
s.restartMutex.Unlock()
|
||||
if restartErr != nil {
|
||||
s.logger.ErrorContext(context.Background(), fmt.Sprintf("process restart after task: %v", restartErr))
|
||||
@@ -510,15 +562,15 @@ func (s *processSupervisor) maybeRestartAfterTask(logger *slog.Logger) bool {
|
||||
|
||||
// doRestart coordinates a process restart, draining all active concurrent
|
||||
// tasks before stopping and restarting the process.
|
||||
func (s *processSupervisor) doRestart(ctx context.Context) error {
|
||||
func (s *processSupervisor) doRestart(ctx context.Context, reason string) error {
|
||||
s.restartMutex.Lock()
|
||||
defer s.restartMutex.Unlock()
|
||||
|
||||
return s.doRestartLocked(ctx)
|
||||
return s.doRestartLocked(ctx, reason)
|
||||
}
|
||||
|
||||
// doRestartLocked performs the restart drain logic. The caller must hold restartMutex.
|
||||
func (s *processSupervisor) doRestartLocked(ctx context.Context) error {
|
||||
func (s *processSupervisor) doRestartLocked(ctx context.Context, reason string) error {
|
||||
s.isRestarting.Store(true)
|
||||
defer s.isRestarting.Store(false)
|
||||
|
||||
@@ -538,8 +590,8 @@ func (s *processSupervisor) doRestartLocked(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
err := s.runWithDeadline(ctx, func() error {
|
||||
return s.restart()
|
||||
err := s.tracedLaunch(ctx, reason, func() error {
|
||||
return s.runWithDeadline(ctx, s.restart)
|
||||
})
|
||||
|
||||
for range acquired {
|
||||
@@ -577,6 +629,13 @@ func (s *processSupervisor) ActiveTasksCount() int64 {
|
||||
return s.activeTasks.Load()
|
||||
}
|
||||
|
||||
// ConversionsSinceRestart returns the number of tasks handled since the last
|
||||
// process (re)start. reqCounter is reset to zero on every restart and idle
|
||||
// shutdown.
|
||||
func (s *processSupervisor) ConversionsSinceRestart() int64 {
|
||||
return s.reqCounter.Load()
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ ProcessSupervisor = (*processSupervisor)(nil)
|
||||
|
||||
@@ -45,7 +45,7 @@ func TestProcessSupervisor_Launch(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
|
||||
if tc.firstStartSet {
|
||||
ps.firstStart.Store(true)
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func TestProcessSupervisor_Shutdown(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0)
|
||||
err := ps.Shutdown()
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
@@ -145,7 +145,7 @@ func TestProcessSupervisor_restart(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
|
||||
|
||||
err := ps.restart()
|
||||
|
||||
@@ -201,7 +201,7 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
|
||||
if tc.initiallyStarted {
|
||||
ps.firstStart.Store(true)
|
||||
}
|
||||
@@ -229,7 +229,7 @@ func TestProcessSupervisor_Healthy_ConsecutiveFailures(t *testing.T) {
|
||||
HealthyMock: func(_ *slog.Logger) bool { return processHealthy.Load() },
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
|
||||
ps.firstStart.Store(true)
|
||||
|
||||
processHealthy.Store(false)
|
||||
@@ -272,7 +272,7 @@ func TestProcessSupervisor_Healthy_CachesPositiveResult(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
|
||||
ps.firstStart.Store(true)
|
||||
|
||||
for range 5 {
|
||||
@@ -302,7 +302,7 @@ func TestProcessSupervisor_Healthy_DoesNotCacheNegativeResult(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
|
||||
ps.firstStart.Store(true)
|
||||
|
||||
processHealthy.Store(false)
|
||||
@@ -489,7 +489,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit, tc.maxQueueSize, 1, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, "test", process, tc.maxReqLimit, tc.maxQueueSize, 1, 0).(*processSupervisor)
|
||||
if tc.initiallyStarted {
|
||||
ps.firstStart.Store(true)
|
||||
}
|
||||
@@ -573,7 +573,7 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
ps := NewProcessSupervisor(slog.New(slog.DiscardHandler), new(ProcessMock), 0, 0, 1, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(slog.New(slog.DiscardHandler), "test", new(ProcessMock), 0, 0, 1, 0).(*processSupervisor)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
if tc.ctxDone {
|
||||
@@ -607,7 +607,7 @@ func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
|
||||
return true
|
||||
},
|
||||
}
|
||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, 0).(*processSupervisor)
|
||||
|
||||
// Simulating a lock.
|
||||
ps.semaphore <- struct{}{}
|
||||
@@ -668,7 +668,7 @@ func TestProcessSupervisor_QueueSizeCAS(t *testing.T) {
|
||||
|
||||
maxQueueSize := int64(50)
|
||||
// maxConcurrency=1 so all goroutines block on the semaphore, exercising queue logic.
|
||||
ps := NewProcessSupervisor(logger, process, 0, maxQueueSize, 1, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 0, maxQueueSize, 1, 0).(*processSupervisor)
|
||||
|
||||
// Simulating a lock so that all goroutines queue up.
|
||||
ps.semaphore <- struct{}{}
|
||||
@@ -722,7 +722,7 @@ func TestProcessSupervisor_QueueSizeIncludesActiveTasks(t *testing.T) {
|
||||
}
|
||||
|
||||
// maxQueueSize=1, maxConcurrency=1: only one request at a time.
|
||||
ps := NewProcessSupervisor(logger, process, 0, 1, 1, 0)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 0, 1, 1, 0)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -811,7 +811,7 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, 0).(*processSupervisor)
|
||||
ps.restartsCounter.Store(tc.initialRestartsCount)
|
||||
|
||||
for i := 0; i < tc.restartAttempts; i++ {
|
||||
@@ -844,7 +844,7 @@ func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
|
||||
}
|
||||
|
||||
maxConcurrency := int64(3)
|
||||
ps := NewProcessSupervisor(logger, process, 0, 0, maxConcurrency, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 0, 0, maxConcurrency, 0).(*processSupervisor)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -906,7 +906,7 @@ func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) {
|
||||
}
|
||||
|
||||
maxConcurrency := int64(3)
|
||||
ps := NewProcessSupervisor(logger, process, 3, 0, maxConcurrency, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 3, 0, maxConcurrency, 0).(*processSupervisor)
|
||||
ps.firstStart.Store(true)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -963,7 +963,7 @@ func TestProcessSupervisor_IdleShutdown(t *testing.T) {
|
||||
}
|
||||
|
||||
idleTimeout := 50 * time.Millisecond
|
||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, idleTimeout).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, idleTimeout).(*processSupervisor)
|
||||
|
||||
ctx := context.Background()
|
||||
err := ps.Run(ctx, logger, func() error {
|
||||
@@ -1023,7 +1023,7 @@ func TestProcessSupervisor_RetryAfterFailedFirstStart(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, 0).(*processSupervisor)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -1069,7 +1069,7 @@ func TestProcessSupervisor_IdleShutdownSkippedWhenActive(t *testing.T) {
|
||||
}
|
||||
|
||||
idleTimeout := 50 * time.Millisecond
|
||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, idleTimeout)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, idleTimeout)
|
||||
|
||||
ctx := context.Background()
|
||||
go func() {
|
||||
@@ -1091,3 +1091,70 @@ func TestProcessSupervisor_IdleShutdownSkippedWhenActive(t *testing.T) {
|
||||
|
||||
close(taskDone)
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_ConversionsSinceRestart(t *testing.T) {
|
||||
process := &ProcessMock{
|
||||
StartMock: func(*slog.Logger) error { return nil },
|
||||
StopMock: func(*slog.Logger) error { return nil },
|
||||
HealthyMock: func(*slog.Logger) bool { return true },
|
||||
}
|
||||
s := NewProcessSupervisor(slog.New(slog.DiscardHandler), "test", process, 0, 0, 1, 0).(*processSupervisor)
|
||||
|
||||
if got := s.ConversionsSinceRestart(); got != 0 {
|
||||
t.Errorf("expected 0 conversions initially, got %d", got)
|
||||
}
|
||||
|
||||
s.reqCounter.Store(7)
|
||||
if got := s.ConversionsSinceRestart(); got != 7 {
|
||||
t.Errorf("expected 7 conversions, got %d", got)
|
||||
}
|
||||
|
||||
if err := s.restart(); err != nil {
|
||||
t.Fatalf("restart: %v", err)
|
||||
}
|
||||
if got := s.ConversionsSinceRestart(); got != 0 {
|
||||
t.Errorf("expected 0 conversions after restart, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_RunEmitsSubSpans(t *testing.T) {
|
||||
recorder := newTestSpanRecorder(t)
|
||||
|
||||
process := &ProcessMock{
|
||||
StartMock: func(*slog.Logger) error { return nil },
|
||||
StopMock: func(*slog.Logger) error { return nil },
|
||||
HealthyMock: func(*slog.Logger) bool { return true },
|
||||
}
|
||||
s := NewProcessSupervisor(slog.New(slog.DiscardHandler), "chromium", process, 0, 0, 1, 0)
|
||||
|
||||
err := s.Run(context.Background(), slog.New(slog.DiscardHandler), func() error { return nil })
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
|
||||
var queueWaits, processStarts int
|
||||
var startReason string
|
||||
for _, span := range recorder.Ended() {
|
||||
switch span.Name() {
|
||||
case "chromium.queue.wait":
|
||||
queueWaits++
|
||||
case "chromium.process.start":
|
||||
processStarts++
|
||||
for _, kv := range span.Attributes() {
|
||||
if string(kv.Key) == "gotenberg.process.start.reason" {
|
||||
startReason = kv.Value.AsString()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if queueWaits != 1 {
|
||||
t.Errorf("expected 1 chromium.queue.wait span, got %d", queueWaits)
|
||||
}
|
||||
if processStarts != 1 {
|
||||
t.Errorf("expected 1 chromium.process.start span (first start), got %d", processStarts)
|
||||
}
|
||||
if startReason != "first_start" {
|
||||
t.Errorf("expected process start reason first_start, got %q", startReason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,11 @@ const (
|
||||
DebugLoggingLevel = "debug"
|
||||
)
|
||||
|
||||
const (
|
||||
LowerLevelCase = "lower"
|
||||
UpperLevelCase = "upper"
|
||||
)
|
||||
|
||||
// TelemetryConfig gathers the configuration data for Gotenberg's telemetry.
|
||||
type TelemetryConfig struct {
|
||||
ServiceName string
|
||||
@@ -38,6 +43,7 @@ type TelemetryConfig struct {
|
||||
LogFieldsPrefix string
|
||||
LogStdFormat string
|
||||
LogStdEnableGcpFields bool
|
||||
LogStdLevelCase string
|
||||
}
|
||||
|
||||
func (cfg TelemetryConfig) slogLevel() slog.Level {
|
||||
@@ -85,6 +91,16 @@ func (cfg TelemetryConfig) Validate() error {
|
||||
)
|
||||
}
|
||||
|
||||
switch cfg.LogStdLevelCase {
|
||||
case LowerLevelCase, UpperLevelCase:
|
||||
break
|
||||
default:
|
||||
err = errors.Join(
|
||||
err,
|
||||
fmt.Errorf("standard log level case must be either %s or %s", LowerLevelCase, UpperLevelCase),
|
||||
)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -92,7 +108,7 @@ func (cfg TelemetryConfig) Validate() error {
|
||||
func StartTelemetry(cfg TelemetryConfig) (shutdown func(context.Context) error, err error) {
|
||||
var handlers []slog.Handler
|
||||
|
||||
stdHandler, err := log.NewStdHandler(cfg.slogLevel(), cfg.LogStdFormat, cfg.LogFieldsPrefix, cfg.LogStdEnableGcpFields)
|
||||
stdHandler, err := log.NewStdHandler(cfg.slogLevel(), cfg.LogStdFormat, cfg.LogFieldsPrefix, cfg.LogStdEnableGcpFields, cfg.LogStdLevelCase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get standard logger handler: %w", err)
|
||||
}
|
||||
|
||||
@@ -57,12 +57,13 @@ type Api struct {
|
||||
}
|
||||
|
||||
type downloadFromConfig struct {
|
||||
allowList []*regexp2.Regexp
|
||||
denyList []*regexp2.Regexp
|
||||
denyPrivateIPs bool
|
||||
denyPublicIPs bool
|
||||
maxRetry int
|
||||
disable bool
|
||||
allowList []*regexp2.Regexp
|
||||
denyList []*regexp2.Regexp
|
||||
denyPrivateIPs bool
|
||||
denyPublicIPs bool
|
||||
enableEnvironmentProxy bool
|
||||
maxRetry int
|
||||
disable bool
|
||||
}
|
||||
|
||||
// Router is a module interface that adds routes to the [Api].
|
||||
@@ -201,6 +202,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
||||
fs.StringSlice("api-download-from-deny-list", []string{}, "Set the denied URLs for the download from feature using regular expressions - supports multiple values")
|
||||
fs.Bool("api-download-from-deny-private-ips", false, "Reject downloadFrom URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted downloadFrom sources to mitigate SSRF against internal services")
|
||||
fs.Bool("api-download-from-deny-public-ips", false, "Reject downloadFrom URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent downloads from reaching the public internet")
|
||||
fs.Bool("api-download-from-enable-environment-proxy", false, "Route downloadFrom fetches through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials")
|
||||
fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature")
|
||||
fs.Bool("api-disable-download-from", false, "Disable the download from feature")
|
||||
fs.Bool("api-disable-health-check-route-telemetry", true, "Disable telemetry for health check route")
|
||||
@@ -239,12 +241,13 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
||||
a.rootPath = flags.MustString("api-root-path")
|
||||
a.correlationIdHeader = flags.MustDeprecatedString("api-trace-header", "api-correlation-id-header")
|
||||
a.downloadFromCfg = downloadFromConfig{
|
||||
allowList: flags.MustRegexpSlice("api-download-from-allow-list"),
|
||||
denyList: flags.MustRegexpSlice("api-download-from-deny-list"),
|
||||
denyPrivateIPs: flags.MustBool("api-download-from-deny-private-ips"),
|
||||
denyPublicIPs: flags.MustBool("api-download-from-deny-public-ips"),
|
||||
maxRetry: flags.MustInt("api-download-from-max-retry"),
|
||||
disable: flags.MustBool("api-disable-download-from"),
|
||||
allowList: flags.MustRegexpSlice("api-download-from-allow-list"),
|
||||
denyList: flags.MustRegexpSlice("api-download-from-deny-list"),
|
||||
denyPrivateIPs: flags.MustBool("api-download-from-deny-private-ips"),
|
||||
denyPublicIPs: flags.MustBool("api-download-from-deny-public-ips"),
|
||||
enableEnvironmentProxy: flags.MustBool("api-download-from-enable-environment-proxy"),
|
||||
maxRetry: flags.MustInt("api-download-from-max-retry"),
|
||||
disable: flags.MustBool("api-disable-download-from"),
|
||||
}
|
||||
a.disableHealthCheckRouteTelemetry = flags.MustDeprecatedBool("api-disable-health-check-logging", "api-disable-health-check-route-telemetry")
|
||||
a.disableRootRouteTelemetry = flags.MustBool("api-disable-root-route-telemetry")
|
||||
|
||||
@@ -111,7 +111,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
if bodyLimit != 0 && newTotal > bodyLimit {
|
||||
return WrapError(
|
||||
fmt.Errorf("body limit reached (> %d)", bodyLimit),
|
||||
NewSentinelHttpError(http.StatusRequestEntityTooLarge, http.StatusText(http.StatusRequestEntityTooLarge)),
|
||||
NewSentinelHttpError(http.StatusRequestEntityTooLarge, "The request body exceeds the configured size limit. Increase it with --api-body-limit, or send a smaller request."),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
@@ -281,7 +281,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
}
|
||||
|
||||
client := &retryablehttp.Client{
|
||||
HTTPClient: gotenberg.NewOutboundHttpClient(time.Until(deadline), downloadFromCfg.allowList, downloadFromCfg.denyList, ipOpts...),
|
||||
HTTPClient: gotenberg.NewOutboundHttpClient(time.Until(deadline), downloadFromCfg.allowList, downloadFromCfg.denyList, downloadFromCfg.enableEnvironmentProxy, ipOpts...),
|
||||
RetryMax: downloadFromCfg.maxRetry,
|
||||
RetryWaitMin: time.Duration(1) * time.Second,
|
||||
RetryWaitMax: time.Until(deadline),
|
||||
@@ -536,6 +536,11 @@ func (ctx *Context) FormData() *FormData {
|
||||
}
|
||||
}
|
||||
|
||||
// FileCount returns the number of files received in the request.
|
||||
func (ctx *Context) FileCount() int {
|
||||
return len(ctx.files)
|
||||
}
|
||||
|
||||
// OriginalFilename returns the original filename associated with a disk path.
|
||||
// If no mapping exists, it falls back to [filepath.Base].
|
||||
func (ctx *Context) OriginalFilename(diskPath string) string {
|
||||
|
||||
@@ -206,3 +206,19 @@ func TestSanitizeFilename(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_FileCount(t *testing.T) {
|
||||
ctx := &Context{}
|
||||
if got := ctx.FileCount(); got != 0 {
|
||||
t.Errorf("expected 0 files, got %d", got)
|
||||
}
|
||||
|
||||
ctx.files = map[string]string{
|
||||
"index.html": "/work/index.html",
|
||||
"header.html": "/work/header.html",
|
||||
"styles.css": "/work/styles.css",
|
||||
}
|
||||
if got := ctx.FileCount(); got != 3 {
|
||||
t.Errorf("expected 3 files, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ const (
|
||||
|
||||
// StampFormField represents the form field name for the stamp file.
|
||||
StampFormField string = "stamp"
|
||||
|
||||
// FacturXXmlFormField represents the form field name for the Factur-X CII
|
||||
// invoice XML file.
|
||||
FacturXXmlFormField string = "facturxXml"
|
||||
)
|
||||
|
||||
// FormData is a helper for validating and hydrating values from a
|
||||
@@ -475,13 +479,28 @@ func (form *FormData) Stamp(target *string) *FormData {
|
||||
return form
|
||||
}
|
||||
|
||||
// FacturXXml binds the absolute path of the uploaded Factur-X CII invoice
|
||||
// XML. Only a file uploaded with the "facturxXml" field name is included.
|
||||
func (form *FormData) FacturXXml(target *string) *FormData {
|
||||
if form.errors != nil {
|
||||
return form
|
||||
}
|
||||
|
||||
if paths, ok := form.filesByField[FacturXXmlFormField]; ok && len(paths) > 0 {
|
||||
*target = paths[0]
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// paths bind the absolute paths of form data files, according to a list of
|
||||
// file extensions, to a string slice variable.
|
||||
// embeds, watermark, and stamp files are excluded.
|
||||
// embeds, watermark, stamp, and facturxXml files are excluded.
|
||||
func (form *FormData) paths(extensions []string, target *[]string) *FormData {
|
||||
embeds, ok := form.filesByField[EmbedsFormField]
|
||||
watermarks, wmOk := form.filesByField[WatermarkFormField]
|
||||
stamps, stOk := form.filesByField[StampFormField]
|
||||
facturxXmls, fxOk := form.filesByField[FacturXXmlFormField]
|
||||
|
||||
// Collect (originalFilename, diskPath) pairs so that we can sort by
|
||||
// original filename rather than by UUID-based disk name.
|
||||
@@ -505,6 +524,10 @@ func (form *FormData) paths(extensions []string, target *[]string) *FormData {
|
||||
continue
|
||||
}
|
||||
|
||||
if fxOk && slices.Contains(facturxXmls, path) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ext := range extensions {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/228.
|
||||
if strings.ToLower(filepath.Ext(filename)) == ext {
|
||||
|
||||
@@ -1783,3 +1783,57 @@ func TestFormData_Embeds(t *testing.T) {
|
||||
t.Errorf("expected %v but got %v", expected, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormData_FacturXXml(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
form *FormData
|
||||
expect string
|
||||
}{
|
||||
{
|
||||
scenario: "no facturxXml file",
|
||||
form: &FormData{},
|
||||
expect: "",
|
||||
},
|
||||
{
|
||||
scenario: "facturxXml file present",
|
||||
form: &FormData{
|
||||
filesByField: map[string][]string{
|
||||
FacturXXmlFormField: {"/tmp/abc/12345.xml"},
|
||||
},
|
||||
},
|
||||
expect: "/tmp/abc/12345.xml",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
var actual string
|
||||
|
||||
tc.form.FacturXXml(&actual)
|
||||
|
||||
if actual != tc.expect {
|
||||
t.Errorf("expected %q but got %q", tc.expect, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormData_paths_excludesFacturXXml verifies that an uploaded facturxXml is
|
||||
// never picked up as an input document by paths().
|
||||
func TestFormData_paths_excludesFacturXXml(t *testing.T) {
|
||||
form := &FormData{
|
||||
files: map[string]string{
|
||||
"document.xml": "/tmp/abc/document.xml",
|
||||
"factur-x.xml": "/tmp/abc/invoice.xml",
|
||||
},
|
||||
filesByField: map[string][]string{
|
||||
FacturXXmlFormField: {"/tmp/abc/invoice.xml"},
|
||||
},
|
||||
}
|
||||
|
||||
var paths []string
|
||||
form.paths([]string{".xml"}, &paths)
|
||||
|
||||
if len(paths) != 1 || paths[0] != "/tmp/abc/document.xml" {
|
||||
t.Errorf("expected only the non-Factur-X .xml document, got %+v", paths)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func ParseError(err error) (int, string) {
|
||||
}
|
||||
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return http.StatusServiceUnavailable, http.StatusText(http.StatusServiceUnavailable)
|
||||
return http.StatusServiceUnavailable, "The request exceeded the time limit. Increase it with --api-timeout, or reduce the workload."
|
||||
}
|
||||
|
||||
if errors.Is(err, gotenberg.ErrFiltered) {
|
||||
@@ -51,27 +51,27 @@ func ParseError(err error) (int, string) {
|
||||
}
|
||||
|
||||
if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
|
||||
return http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests)
|
||||
return http.StatusTooManyRequests, "The request queue is full. Retry shortly, or raise the limit with --chromium-max-queue-size or --libreoffice-max-queue-size."
|
||||
}
|
||||
|
||||
if errors.Is(err, gotenberg.ErrPdfSplitModeNotSupported) {
|
||||
return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues"
|
||||
return http.StatusBadRequest, "The requested split mode is not supported, or no PDF engine could process it. Valid modes: 'intervals', 'pages'."
|
||||
}
|
||||
|
||||
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
|
||||
return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues"
|
||||
return http.StatusBadRequest, "The requested PDF format is not supported, or no PDF engine could apply it. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA."
|
||||
}
|
||||
|
||||
if errors.Is(err, gotenberg.ErrPdfEngineMetadataValueNotSupported) {
|
||||
return http.StatusBadRequest, "At least one PDF engine cannot process the requested metadata, while others may have failed to convert due to different issues"
|
||||
return http.StatusBadRequest, "The requested metadata could not be written; ensure values are valid and free of control characters."
|
||||
}
|
||||
|
||||
if errors.Is(err, gotenberg.ErrPdfStampSourceNotSupported) {
|
||||
return http.StatusBadRequest, "At least one PDF engine cannot process the requested stamp source type, while others may have failed due to different issues"
|
||||
return http.StatusBadRequest, "The requested stamp source is not supported, or no PDF engine could process it. Valid sources: 'text', 'image', 'pdf'."
|
||||
}
|
||||
|
||||
if errors.Is(err, gotenberg.ErrPdfRotateAngleNotSupported) {
|
||||
return http.StatusBadRequest, "At least one PDF engine cannot process the requested rotation angle, while others may have failed due to different issues"
|
||||
return http.StatusBadRequest, "The requested rotation angle is not supported. Valid angles: 90, 180, 270."
|
||||
}
|
||||
|
||||
if invalidArgsError, ok := errors.AsType[*gotenberg.PdfEngineInvalidArgsError](err); ok {
|
||||
|
||||
87
pkg/modules/chromium/attrs_test.go
Normal file
87
pkg/modules/chromium/attrs_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||
)
|
||||
|
||||
func TestPrintToPdfAttrs(t *testing.T) {
|
||||
options := DefaultPdfOptions()
|
||||
options.Landscape = true
|
||||
options.PageRanges = "1-5"
|
||||
options.HeaderTemplate = "<div>secret header</div>"
|
||||
// FooterTemplate left at default, so has_footer must be false.
|
||||
|
||||
got := map[string]attribute.Value{}
|
||||
for _, kv := range printToPdfAttrs(options) {
|
||||
got[string(kv.Key)] = kv.Value
|
||||
if s := kv.Value.AsString(); strings.Contains(s, "secret") || s == "1-5" {
|
||||
t.Errorf("attribute %s leaked a raw value: %q", kv.Key, s)
|
||||
}
|
||||
}
|
||||
|
||||
if !got["gotenberg.chromium.print.landscape"].AsBool() {
|
||||
t.Error("expected landscape=true")
|
||||
}
|
||||
if !got["gotenberg.chromium.print.has_page_ranges"].AsBool() {
|
||||
t.Error("expected has_page_ranges=true")
|
||||
}
|
||||
if !got["gotenberg.chromium.print.has_header"].AsBool() {
|
||||
t.Error("expected has_header=true")
|
||||
}
|
||||
if got["gotenberg.chromium.print.has_footer"].AsBool() {
|
||||
t.Error("expected has_footer=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversionInputAttrs(t *testing.T) {
|
||||
tmp := filepath.Join(t.TempDir(), "index.html")
|
||||
content := []byte("<html></html>")
|
||||
if err := os.WriteFile(tmp, content, 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
t.Run("file URL with non-api context", func(t *testing.T) {
|
||||
got := map[string]int64{}
|
||||
for _, kv := range conversionInputAttrs(context.Background(), "file://"+tmp) {
|
||||
got[string(kv.Key)] = kv.Value.AsInt64()
|
||||
}
|
||||
|
||||
if _, ok := got["gotenberg.conversion.input.files.count"]; ok {
|
||||
t.Error("did not expect files.count for a non-api context")
|
||||
}
|
||||
if got["gotenberg.conversion.input.html.bytes"] != int64(len(content)) {
|
||||
t.Errorf("expected html.bytes=%d, got %d", len(content), got["gotenberg.conversion.input.html.bytes"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("remote URL yields no html.bytes", func(t *testing.T) {
|
||||
for _, kv := range conversionInputAttrs(context.Background(), "https://example.com") {
|
||||
if string(kv.Key) == "gotenberg.conversion.input.html.bytes" {
|
||||
t.Error("did not expect html.bytes for a remote URL")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("api context yields files.count", func(t *testing.T) {
|
||||
var found bool
|
||||
for _, kv := range conversionInputAttrs(&api.Context{}, "https://example.com") {
|
||||
if string(kv.Key) == "gotenberg.conversion.input.files.count" {
|
||||
found = true
|
||||
if kv.Value.AsInt64() != 0 {
|
||||
t.Errorf("expected files.count=0, got %d", kv.Value.AsInt64())
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected files.count for an api context")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -25,8 +25,8 @@ import (
|
||||
|
||||
type browser interface {
|
||||
gotenberg.Process
|
||||
pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error
|
||||
screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error
|
||||
pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions, aggregate *networkAggregate) error
|
||||
screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions, aggregate *networkAggregate) error
|
||||
}
|
||||
|
||||
type browserArguments struct {
|
||||
@@ -38,6 +38,7 @@ type browserArguments struct {
|
||||
allowFileAccessFromFiles bool
|
||||
hostResolverRules string
|
||||
proxyServer string
|
||||
enableEnvironmentProxy bool
|
||||
wsUrlReadTimeout time.Duration
|
||||
hyphenDataDirPath string
|
||||
|
||||
@@ -58,6 +59,14 @@ type chromiumBrowser struct {
|
||||
userProfileDirPath string
|
||||
ctxMu sync.RWMutex
|
||||
isStarted atomic.Bool
|
||||
// startMu serializes Start calls. The supervisor's runWithDeadline
|
||||
// abandons a Start goroutine when the request deadline expires while
|
||||
// Chromium's startup handshake is still hanging; the abandoned goroutine
|
||||
// keeps running, holding the resources it acquired (the pinning proxy).
|
||||
// Serializing here prevents a second, overlapping Start from colliding
|
||||
// with the in-flight one on the shared pinning proxy.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1599.
|
||||
startMu sync.Mutex
|
||||
|
||||
arguments browserArguments
|
||||
fs *gotenberg.FileSystem
|
||||
@@ -69,7 +78,7 @@ func newChromiumBrowser(arguments browserArguments) browser {
|
||||
initialCtx: context.Background(),
|
||||
arguments: arguments,
|
||||
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
|
||||
pinningProxy: newPinningProxy(arguments.allowList, arguments.denyList, arguments.denyPrivateIPs, arguments.denyPublicIPs),
|
||||
pinningProxy: newPinningProxy(arguments.allowList, arguments.denyList, arguments.denyPrivateIPs, arguments.denyPublicIPs, arguments.enableEnvironmentProxy),
|
||||
}
|
||||
b.isStarted.Store(false)
|
||||
|
||||
@@ -77,6 +86,19 @@ func newChromiumBrowser(arguments browserArguments) browser {
|
||||
}
|
||||
|
||||
func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
||||
// Refuse to run while a previous Start is still in flight. That previous
|
||||
// Start may be a goroutine the supervisor abandoned after the request
|
||||
// deadline expired while the Chromium startup handshake was hanging; it
|
||||
// still holds the pinning proxy it started. An abandoned goroutine keeps
|
||||
// holding startMu until it unwinds (bounded by --chromium-start-timeout),
|
||||
// so no overlapping Start can collide with it on the shared pinning proxy
|
||||
// and latch Chromium into a permanent "pinning proxy already started"
|
||||
// state. See https://github.com/gotenberg/gotenberg/issues/1599.
|
||||
if !b.startMu.TryLock() {
|
||||
return errors.New("browser start already in progress")
|
||||
}
|
||||
defer b.startMu.Unlock()
|
||||
|
||||
if b.isStarted.Load() {
|
||||
return errors.New("browser is already started")
|
||||
}
|
||||
@@ -314,10 +336,10 @@ func (b *chromiumBrowser) Healthy(logger *slog.Logger) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
|
||||
func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions, aggregate *networkAggregate) error {
|
||||
// Note: no error wrapping because it leaks on errors we want to display to
|
||||
// the end user.
|
||||
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
|
||||
return b.do(ctx, logger, url, options.Options, aggregate, chromedp.Tasks{
|
||||
network.Enable(),
|
||||
fetch.Enable(),
|
||||
runtime.Enable(),
|
||||
@@ -334,16 +356,16 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, out
|
||||
waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector),
|
||||
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
|
||||
// PDF specific.
|
||||
printToPdfActionFunc(logger, outputPath, options),
|
||||
printToPdfActionFunc(ctx, logger, outputPath, options),
|
||||
// Teardown.
|
||||
page.Close(),
|
||||
})
|
||||
}
|
||||
|
||||
func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions, aggregate *networkAggregate) error {
|
||||
// Note: no error wrapping because it leaks on errors we want to display to
|
||||
// the end user.
|
||||
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
|
||||
return b.do(ctx, logger, url, options.Options, aggregate, chromedp.Tasks{
|
||||
network.Enable(),
|
||||
fetch.Enable(),
|
||||
runtime.Enable(),
|
||||
@@ -367,7 +389,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, u
|
||||
})
|
||||
}
|
||||
|
||||
func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url string, options Options, tasks chromedp.Tasks) error {
|
||||
func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url string, options Options, aggregate *networkAggregate, tasks chromedp.Tasks) error {
|
||||
if !b.isStarted.Load() {
|
||||
return errors.New("browser not started, cannot handle tasks")
|
||||
}
|
||||
@@ -396,6 +418,9 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
||||
taskCtx, taskCancel := chromedp.NewContext(timeoutCtx)
|
||||
defer taskCancel()
|
||||
|
||||
// Accumulate per-conversion network activity for telemetry.
|
||||
listenForNetworkActivity(taskCtx, aggregate)
|
||||
|
||||
// We validate all other requests against our allowed / deny lists.
|
||||
// If a request does not pass the validation, we make it fail. It also set
|
||||
// the extra HTTP headers, if any.
|
||||
|
||||
39
pkg/modules/chromium/browser_test.go
Normal file
39
pkg/modules/chromium/browser_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestChromiumBrowser_Start_rejectsOverlappingStart guards against the latch
|
||||
// reported in https://github.com/gotenberg/gotenberg/issues/1599. When the
|
||||
// supervisor abandons a Start goroutine on request-deadline expiry, that
|
||||
// goroutine keeps running and holds startMu (and the pinning proxy it started)
|
||||
// until it unwinds. A second Start must be refused rather than proceeding to
|
||||
// start the pinning proxy a second time.
|
||||
func TestChromiumBrowser_Start_rejectsOverlappingStart(t *testing.T) {
|
||||
b := &chromiumBrowser{initialCtx: context.Background()}
|
||||
|
||||
// Simulate a Start still in flight.
|
||||
b.startMu.Lock()
|
||||
defer b.startMu.Unlock()
|
||||
|
||||
err := b.Start(slog.New(slog.DiscardHandler))
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when a start is already in progress, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "already in progress") {
|
||||
t.Fatalf("expected an 'already in progress' error, got %q", err)
|
||||
}
|
||||
|
||||
// The guard must return before touching any startup resource, so no user
|
||||
// profile directory is created and the browser stays not started.
|
||||
if b.userProfileDirPath != "" {
|
||||
t.Fatalf("expected no user profile directory to be created, got %q", b.userProfileDirPath)
|
||||
}
|
||||
if b.isStarted.Load() {
|
||||
t.Fatal("expected the browser to stay not started")
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -101,12 +102,17 @@ type Chromium struct {
|
||||
supervisor gotenberg.ProcessSupervisor
|
||||
engine gotenberg.PdfEngine
|
||||
|
||||
version string
|
||||
versionOnce sync.Once
|
||||
|
||||
reqsCounter metric.Int64Counter
|
||||
errsCounter metric.Int64Counter
|
||||
conversionDurationCounter metric.Float64Histogram
|
||||
queueWaitDurationCounter metric.Float64Histogram
|
||||
pdfOutputSizeCounter metric.Int64Histogram
|
||||
imageOutputSizeCounter metric.Int64Histogram
|
||||
networkRequestsCounter metric.Int64Counter
|
||||
networkBytesCounter metric.Int64Histogram
|
||||
}
|
||||
|
||||
// Options are the common options for all conversions.
|
||||
@@ -294,7 +300,8 @@ type PdfOptions struct {
|
||||
PreferCssPageSize bool
|
||||
|
||||
// GenerateDocumentOutline defines whether the document outline should be
|
||||
// embedded into the PDF.
|
||||
// embedded into the PDF. Chromium derives the outline from the tagged-PDF
|
||||
// structure tree, so enabling this implies GenerateTaggedPdf.
|
||||
GenerateDocumentOutline bool
|
||||
|
||||
// GenerateTaggedPdf defines whether to generate tagged (accessible)
|
||||
@@ -453,6 +460,7 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
|
||||
fs.Bool("chromium-allow-file-access-from-files", false, "Allow file:// URIs to read other file:// URIs")
|
||||
fs.String("chromium-host-resolver-rules", "", "Set custom mappings to the host resolver")
|
||||
fs.String("chromium-proxy-server", "", "Set the outbound proxy server; this switch only affects HTTP and HTTPS requests")
|
||||
fs.Bool("chromium-enable-environment-proxy", false, "Route Chromium's outbound requests through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials. Use this instead of --chromium-proxy-server for authenticated proxies, and leave --chromium-proxy-server and --chromium-host-resolver-rules unset")
|
||||
fs.StringSlice("chromium-allow-list", []string{}, "Set the allowed URLs for Chromium using regular expressions - supports multiple values")
|
||||
fs.StringSlice("chromium-deny-list", []string{`^file:(?!//\/tmp/).*`}, "Set the denied URLs for Chromium using regular expressions - supports multiple values")
|
||||
fs.Bool("chromium-deny-private-ips", false, "Reject URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted form input to mitigate SSRF against internal services")
|
||||
@@ -484,12 +492,12 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
||||
|
||||
binPath, ok := os.LookupEnv("CHROMIUM_BIN_PATH")
|
||||
if !ok {
|
||||
return errors.New("CHROMIUM_BIN_PATH environment variable is not set")
|
||||
return errors.New("CHROMIUM_BIN_PATH environment variable is not set; set it to the absolute path of the Chromium or Chrome binary")
|
||||
}
|
||||
|
||||
hyphenDataDirPath, ok := os.LookupEnv("CHROMIUM_HYPHEN_DATA_DIR_PATH")
|
||||
if !ok {
|
||||
return errors.New("CHROMIUM_HYPHEN_DATA_DIR_PATH environment variable is not set")
|
||||
return errors.New("CHROMIUM_HYPHEN_DATA_DIR_PATH environment variable is not set; set it to the absolute path of the Chromium hyphenation data directory (it ships in the Gotenberg image)")
|
||||
}
|
||||
|
||||
mod.args = browserArguments{
|
||||
@@ -500,6 +508,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
||||
allowFileAccessFromFiles: flags.MustBool("chromium-allow-file-access-from-files"),
|
||||
hostResolverRules: flags.MustString("chromium-host-resolver-rules"),
|
||||
proxyServer: flags.MustString("chromium-proxy-server"),
|
||||
enableEnvironmentProxy: flags.MustBool("chromium-enable-environment-proxy"),
|
||||
wsUrlReadTimeout: flags.MustDuration("chromium-start-timeout"),
|
||||
hyphenDataDirPath: hyphenDataDirPath,
|
||||
|
||||
@@ -517,7 +526,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
||||
|
||||
// Process.
|
||||
mod.browser = newChromiumBrowser(mod.args)
|
||||
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"), mod.maxConcurrency, flags.MustDuration("chromium-idle-shutdown-timeout"))
|
||||
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, "chromium", mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"), mod.maxConcurrency, flags.MustDuration("chromium-idle-shutdown-timeout"))
|
||||
|
||||
// PDF Engine.
|
||||
provider, err := ctx.Module(new(gotenberg.PdfEngineProvider))
|
||||
@@ -633,6 +642,24 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
||||
return fmt.Errorf("create chromium.image.output.size histogram: %w", err)
|
||||
}
|
||||
|
||||
mod.networkRequestsCounter, err = meter.Int64Counter(
|
||||
"chromium.network.requests.total",
|
||||
metric.WithDescription("Total number of network requests made during Chromium conversions"),
|
||||
metric.WithUnit("{request}"),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create chromium.network.requests.total counter: %w", err)
|
||||
}
|
||||
|
||||
mod.networkBytesCounter, err = meter.Int64Histogram(
|
||||
"chromium.network.bytes",
|
||||
metric.WithDescription("Bytes fetched over the network during a Chromium conversion"),
|
||||
metric.WithUnit("By"),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create chromium.network.bytes histogram: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -644,12 +671,12 @@ func (mod *Chromium) Validate() error {
|
||||
|
||||
_, err := os.Stat(mod.args.binPath)
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("chromium binary path does not exist: %w", err)
|
||||
return fmt.Errorf("Chromium binary does not exist at %q; check the CHROMIUM_BIN_PATH environment variable: %w", mod.args.binPath, err)
|
||||
}
|
||||
|
||||
_, err = os.Stat(mod.args.hyphenDataDirPath)
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("chromium hyphen-data directory path does not exist: %w", err)
|
||||
return fmt.Errorf("Chromium hyphenation data directory does not exist at %q; check the CHROMIUM_HYPHEN_DATA_DIR_PATH environment variable (it ships in the Gotenberg image): %w", mod.args.hyphenDataDirPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -697,19 +724,46 @@ func (mod *Chromium) Stop(ctx context.Context) error {
|
||||
|
||||
// Debug returns additional debug data.
|
||||
func (mod *Chromium) Debug() map[string]any {
|
||||
debug := make(map[string]any)
|
||||
return map[string]any{"version": mod.detectVersion()}
|
||||
}
|
||||
|
||||
cmd := exec.Command(mod.args.binPath, "--version") //nolint:gosec
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
// detectVersion resolves the Chromium version once, preferring the value
|
||||
// captured at image build time so it never spawns Chromium at runtime. It falls
|
||||
// back to running chromium --version for local or non-Docker builds.
|
||||
func (mod *Chromium) detectVersion() string {
|
||||
mod.versionOnce.Do(func() {
|
||||
if v, ok := gotenberg.BuildVersion("chromium"); ok {
|
||||
mod.version = v
|
||||
return
|
||||
}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
debug["version"] = err.Error()
|
||||
return debug
|
||||
cmd := exec.Command(mod.args.binPath, "--version") //nolint:gosec
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
mod.version = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
mod.version = strings.TrimSpace(string(output))
|
||||
})
|
||||
|
||||
return mod.version
|
||||
}
|
||||
|
||||
// spanAttrs returns the client-span attributes for a Chromium invocation: the
|
||||
// server address and the Chromium version, plus any extra attributes. The
|
||||
// version rides on every conversion span so a trace records which Chromium
|
||||
// rendered the document.
|
||||
func (mod *Chromium) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue {
|
||||
attrs := make([]attribute.KeyValue, 0, 2+len(extra))
|
||||
attrs = append(attrs, semconv.ServerAddress(mod.args.binPath))
|
||||
if v := mod.detectVersion(); v != "" {
|
||||
attrs = append(attrs, attribute.String("gotenberg.chromium.version", v))
|
||||
}
|
||||
|
||||
debug["version"] = strings.TrimSpace(string(output))
|
||||
return debug
|
||||
return append(attrs, extra...)
|
||||
}
|
||||
|
||||
// Metrics returns the metrics.
|
||||
@@ -799,19 +853,32 @@ func (mod *Chromium) Routes() ([]api.Route, error) {
|
||||
}
|
||||
|
||||
// Pdf converts a URL to PDF.
|
||||
//
|
||||
//nolint:dupl
|
||||
func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
|
||||
// Read input attributes before Start rebinds ctx to the span context, which
|
||||
// would shadow the underlying [api.Context].
|
||||
inputAttrs := conversionInputAttrs(ctx, url)
|
||||
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Pdf",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)),
|
||||
trace.WithAttributes(mod.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
span.SetAttributes(inputAttrs...)
|
||||
span.SetAttributes(
|
||||
attribute.Int64("gotenberg.queue.depth_at_arrival", mod.supervisor.ReqQueueSize()),
|
||||
attribute.Int64("gotenberg.conversions_since_last_restart", mod.supervisor.ConversionsSinceRestart()),
|
||||
)
|
||||
|
||||
start := time.Now()
|
||||
var conversionStart time.Time
|
||||
|
||||
aggregate := newNetworkAggregate()
|
||||
err := mod.supervisor.Run(ctx, logger, func() error {
|
||||
conversionStart = time.Now()
|
||||
return mod.browser.pdf(ctx, logger, url, outputPath, options)
|
||||
return mod.browser.pdf(ctx, logger, url, outputPath, options, aggregate)
|
||||
})
|
||||
|
||||
end := time.Now()
|
||||
@@ -824,21 +891,12 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
|
||||
status = "error"
|
||||
}
|
||||
|
||||
reason := "unknown"
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
reason = "timeout"
|
||||
case errors.Is(err, context.Canceled):
|
||||
reason = "context_cancelled"
|
||||
case errors.Is(err, ErrInvalidHttpStatusCode) || errors.Is(err, ErrInvalidResourceHttpStatusCode) || errors.Is(err, ErrLoadingFailed) || errors.Is(err, ErrResourceLoadingFailed) || errors.Is(err, ErrInvalidEvaluationExpression) || errors.Is(err, ErrInvalidSelectorQuery):
|
||||
reason = "invalid_input"
|
||||
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) || errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
|
||||
reason = "chromium_unavailable"
|
||||
}
|
||||
reason := chromiumErrorType(err, "chromium_unavailable")
|
||||
|
||||
mod.errsCounter.Add(ctx, 1, metric.WithAttributes(
|
||||
attribute.String("reason", reason),
|
||||
))
|
||||
gotenberg.SpanErrorType(span, reason)
|
||||
}
|
||||
|
||||
if !conversionStart.IsZero() {
|
||||
@@ -862,9 +920,12 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
|
||||
attribute.String("status", status),
|
||||
))
|
||||
|
||||
mod.recordNetwork(ctx, span, aggregate)
|
||||
|
||||
if err == nil {
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
|
||||
mod.pdfOutputSizeCounter.Record(ctx, fileInfo.Size())
|
||||
span.SetAttributes(attribute.Int64("gotenberg.conversion.output.bytes", fileInfo.Size()))
|
||||
}
|
||||
|
||||
span.SetStatus(codes.Ok, "")
|
||||
@@ -876,19 +937,28 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
|
||||
return err
|
||||
}
|
||||
|
||||
// Screenshot captures a screenshot from a URL.
|
||||
//
|
||||
//nolint:dupl
|
||||
func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Screenshot",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)),
|
||||
trace.WithAttributes(mod.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
span.SetAttributes(
|
||||
attribute.Int64("gotenberg.queue.depth_at_arrival", mod.supervisor.ReqQueueSize()),
|
||||
attribute.Int64("gotenberg.conversions_since_last_restart", mod.supervisor.ConversionsSinceRestart()),
|
||||
)
|
||||
|
||||
start := time.Now()
|
||||
var conversionStart time.Time
|
||||
|
||||
aggregate := newNetworkAggregate()
|
||||
err := mod.supervisor.Run(ctx, logger, func() error {
|
||||
conversionStart = time.Now()
|
||||
return mod.browser.screenshot(ctx, logger, url, outputPath, options)
|
||||
return mod.browser.screenshot(ctx, logger, url, outputPath, options, aggregate)
|
||||
})
|
||||
|
||||
end := time.Now()
|
||||
@@ -901,23 +971,12 @@ func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, o
|
||||
status = "error"
|
||||
}
|
||||
|
||||
reason := "unknown"
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
reason = "timeout"
|
||||
case errors.Is(err, context.Canceled):
|
||||
reason = "context_cancelled"
|
||||
case errors.Is(err, ErrInvalidHttpStatusCode) || errors.Is(err, ErrInvalidResourceHttpStatusCode) || errors.Is(err, ErrLoadingFailed) || errors.Is(err, ErrResourceLoadingFailed) || errors.Is(err, ErrInvalidEvaluationExpression) || errors.Is(err, ErrInvalidSelectorQuery):
|
||||
reason = "invalid_input"
|
||||
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded):
|
||||
reason = "chromium_maximum_queue_size_exceeded"
|
||||
case errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
|
||||
reason = "chromium_unavailable"
|
||||
}
|
||||
reason := chromiumErrorType(err, "chromium_maximum_queue_size_exceeded")
|
||||
|
||||
mod.errsCounter.Add(ctx, 1, metric.WithAttributes(
|
||||
attribute.String("reason", reason),
|
||||
))
|
||||
gotenberg.SpanErrorType(span, reason)
|
||||
}
|
||||
|
||||
if !conversionStart.IsZero() {
|
||||
@@ -941,6 +1000,8 @@ func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, o
|
||||
attribute.String("status", status),
|
||||
))
|
||||
|
||||
mod.recordNetwork(ctx, span, aggregate)
|
||||
|
||||
if err == nil {
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
|
||||
mod.imageOutputSizeCounter.Record(ctx, fileInfo.Size())
|
||||
@@ -955,6 +1016,86 @@ func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, o
|
||||
return err
|
||||
}
|
||||
|
||||
// recordNetwork lifts per-conversion network aggregates onto the span and the
|
||||
// network metrics. Counts are dimensioned by outcome and bytes feed a
|
||||
// histogram; both are recorded with the conversion context so the SDK attaches
|
||||
// trace exemplars. The heaviest resource URL is redacted before it lands on the
|
||||
// span event.
|
||||
func (mod *Chromium) recordNetwork(ctx context.Context, span trace.Span, aggregate *networkAggregate) {
|
||||
if aggregate == nil {
|
||||
return
|
||||
}
|
||||
|
||||
stats := aggregate.snapshot()
|
||||
|
||||
span.SetAttributes(
|
||||
attribute.Int64("gotenberg.chromium.resources.count", stats.requestCount),
|
||||
attribute.Int64("gotenberg.chromium.resources.bytes_total", stats.bytesTotal),
|
||||
attribute.Int64("gotenberg.chromium.resources.failed_count", stats.failedCount),
|
||||
attribute.Int64("gotenberg.chromium.resources.unique_origins", stats.uniqueOrigins),
|
||||
)
|
||||
|
||||
if stats.heaviestURL != "" {
|
||||
span.AddEvent("chromium.heaviest_resource", trace.WithAttributes(
|
||||
attribute.String("url", gotenberg.RedactURL(stats.heaviestURL)),
|
||||
attribute.Int64("bytes", stats.heaviestBytes),
|
||||
))
|
||||
}
|
||||
|
||||
if ok := stats.requestCount - stats.failedCount; ok > 0 {
|
||||
mod.networkRequestsCounter.Add(ctx, ok, metric.WithAttributes(attribute.String("outcome", "ok")))
|
||||
}
|
||||
if stats.failedCount > 0 {
|
||||
mod.networkRequestsCounter.Add(ctx, stats.failedCount, metric.WithAttributes(attribute.String("outcome", "failed")))
|
||||
}
|
||||
|
||||
mod.networkBytesCounter.Record(ctx, stats.bytesTotal)
|
||||
}
|
||||
|
||||
// conversionInputAttrs derives low-cardinality input attributes for a
|
||||
// conversion span: the number of received files (when ctx is an [api.Context])
|
||||
// and the size of the local HTML input (when url is a file:// URL). Remote URL
|
||||
// conversions yield no html.bytes.
|
||||
func conversionInputAttrs(ctx context.Context, url string) []attribute.KeyValue {
|
||||
var attrs []attribute.KeyValue
|
||||
|
||||
if apiCtx, ok := ctx.(*api.Context); ok {
|
||||
attrs = append(attrs, attribute.Int("gotenberg.conversion.input.files.count", apiCtx.FileCount()))
|
||||
}
|
||||
|
||||
if after, ok := strings.CutPrefix(url, "file://"); ok {
|
||||
if info, err := os.Stat(after); err == nil {
|
||||
attrs = append(attrs, attribute.Int64("gotenberg.conversion.input.html.bytes", info.Size()))
|
||||
}
|
||||
}
|
||||
|
||||
return attrs
|
||||
}
|
||||
|
||||
// chromiumErrorType maps a conversion error to chromium's bounded reason value,
|
||||
// reused as the span error.type. queueReason preserves the historical,
|
||||
// route-specific label for a saturated queue: Pdf collapses it into
|
||||
// "chromium_unavailable", whereas Screenshot keeps
|
||||
// "chromium_maximum_queue_size_exceeded". Generic failures fall back to
|
||||
// [gotenberg.ClassifyError].
|
||||
func chromiumErrorType(err error, queueReason string) string {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidHttpStatusCode),
|
||||
errors.Is(err, ErrInvalidResourceHttpStatusCode),
|
||||
errors.Is(err, ErrLoadingFailed),
|
||||
errors.Is(err, ErrResourceLoadingFailed),
|
||||
errors.Is(err, ErrInvalidEvaluationExpression),
|
||||
errors.Is(err, ErrInvalidSelectorQuery):
|
||||
return gotenberg.ErrorTypeInvalidInput
|
||||
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded):
|
||||
return queueReason
|
||||
case errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
|
||||
return "chromium_unavailable"
|
||||
default:
|
||||
return gotenberg.ClassifyError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*Chromium)(nil)
|
||||
|
||||
37
pkg/modules/chromium/errortype_test.go
Normal file
37
pkg/modules/chromium/errortype_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestChromiumErrorType(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
queueReason string
|
||||
want string
|
||||
}{
|
||||
{"deadline", context.DeadlineExceeded, "chromium_unavailable", "timeout"},
|
||||
{"canceled", context.Canceled, "chromium_unavailable", "context_cancelled"},
|
||||
{"invalid http status", ErrInvalidHttpStatusCode, "chromium_unavailable", "invalid_input"},
|
||||
{"invalid resource http status", ErrInvalidResourceHttpStatusCode, "chromium_unavailable", "invalid_input"},
|
||||
{"loading failed", ErrLoadingFailed, "chromium_unavailable", "invalid_input"},
|
||||
{"resource loading failed", ErrResourceLoadingFailed, "chromium_unavailable", "invalid_input"},
|
||||
{"invalid evaluation expression", ErrInvalidEvaluationExpression, "chromium_unavailable", "invalid_input"},
|
||||
{"invalid selector query", ErrInvalidSelectorQuery, "chromium_unavailable", "invalid_input"},
|
||||
{"pdf queue", gotenberg.ErrMaximumQueueSizeExceeded, "chromium_unavailable", "chromium_unavailable"},
|
||||
{"screenshot queue", gotenberg.ErrMaximumQueueSizeExceeded, "chromium_maximum_queue_size_exceeded", "chromium_maximum_queue_size_exceeded"},
|
||||
{"restarting", gotenberg.ErrProcessAlreadyRestarting, "chromium_maximum_queue_size_exceeded", "chromium_unavailable"},
|
||||
{"unknown", errors.New("boom"), "chromium_unavailable", "unknown"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := chromiumErrorType(tc.err, tc.queueReason); got != tc.want {
|
||||
t.Errorf("chromiumErrorType(%v, %q) = %q, want %q", tc.err, tc.queueReason, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,26 @@ import (
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
// listenForNetworkActivity accumulates per-conversion network activity into
|
||||
// aggregate from the always-on Network domain events. It is a no-op when
|
||||
// aggregate is nil.
|
||||
func listenForNetworkActivity(ctx context.Context, aggregate *networkAggregate) {
|
||||
if aggregate == nil {
|
||||
return
|
||||
}
|
||||
|
||||
chromedp.ListenTarget(ctx, func(ev any) {
|
||||
switch e := ev.(type) {
|
||||
case *network.EventResponseReceived:
|
||||
aggregate.onResponseReceived(e)
|
||||
case *network.EventLoadingFinished:
|
||||
aggregate.onLoadingFinished(e)
|
||||
case *network.EventLoadingFailed:
|
||||
aggregate.onLoadingFailed(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type eventRequestPausedOptions struct {
|
||||
allowList, denyList []*regexp2.Regexp
|
||||
denyPrivateIPs bool
|
||||
|
||||
@@ -24,16 +24,16 @@ func (api *ApiMock) Screenshot(ctx context.Context, logger *slog.Logger, url, ou
|
||||
// browserMock is a mock for the [browser] interface.
|
||||
type browserMock struct {
|
||||
gotenberg.ProcessMock
|
||||
pdfMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error
|
||||
screenshotMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error
|
||||
pdfMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions, aggregate *networkAggregate) error
|
||||
screenshotMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions, aggregate *networkAggregate) error
|
||||
}
|
||||
|
||||
func (b *browserMock) pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
|
||||
return b.pdfMock(ctx, logger, url, outputPath, options)
|
||||
func (b *browserMock) pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions, aggregate *networkAggregate) error {
|
||||
return b.pdfMock(ctx, logger, url, outputPath, options, aggregate)
|
||||
}
|
||||
|
||||
func (b *browserMock) screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
return b.screenshotMock(ctx, logger, url, outputPath, options)
|
||||
func (b *browserMock) screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions, aggregate *networkAggregate) error {
|
||||
return b.screenshotMock(ctx, logger, url, outputPath, options, aggregate)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
|
||||
121
pkg/modules/chromium/network_aggregate.go
Normal file
121
pkg/modules/chromium/network_aggregate.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"sync"
|
||||
|
||||
"github.com/chromedp/cdproto/network"
|
||||
)
|
||||
|
||||
// maxTrackedOrigins bounds the distinct origins kept per conversion so a
|
||||
// pathological page cannot grow the set without limit.
|
||||
const maxTrackedOrigins = 64
|
||||
|
||||
// networkAggregate accumulates per-conversion network activity from Chromium
|
||||
// DevTools events. It is safe for concurrent use by the chromedp event listener
|
||||
// goroutine and the conversion goroutine that reads the snapshot afterwards.
|
||||
type networkAggregate struct {
|
||||
mu sync.Mutex
|
||||
|
||||
requestCount int64
|
||||
bytesTotal int64
|
||||
failedCount int64
|
||||
|
||||
origins map[string]struct{}
|
||||
requestURLByID map[network.RequestID]string
|
||||
|
||||
heaviestURL string
|
||||
heaviestBytes int64
|
||||
}
|
||||
|
||||
// networkStats is an immutable snapshot of a [networkAggregate].
|
||||
type networkStats struct {
|
||||
requestCount int64
|
||||
bytesTotal int64
|
||||
failedCount int64
|
||||
uniqueOrigins int64
|
||||
heaviestURL string
|
||||
heaviestBytes int64
|
||||
}
|
||||
|
||||
func newNetworkAggregate() *networkAggregate {
|
||||
return &networkAggregate{
|
||||
origins: make(map[string]struct{}),
|
||||
requestURLByID: make(map[network.RequestID]string),
|
||||
}
|
||||
}
|
||||
|
||||
// onResponseReceived records the response origin and remembers the URL for the
|
||||
// request id, so a later loading-finished event can attribute its bytes.
|
||||
func (a *networkAggregate) onResponseReceived(ev *network.EventResponseReceived) {
|
||||
if ev == nil || ev.Response == nil {
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if origin := originOf(ev.Response.URL); origin != "" {
|
||||
if _, ok := a.origins[origin]; !ok && len(a.origins) < maxTrackedOrigins {
|
||||
a.origins[origin] = struct{}{}
|
||||
}
|
||||
}
|
||||
a.requestURLByID[ev.RequestID] = ev.Response.URL
|
||||
}
|
||||
|
||||
// onLoadingFinished records a successfully completed request and its size,
|
||||
// tracking the single heaviest resource.
|
||||
func (a *networkAggregate) onLoadingFinished(ev *network.EventLoadingFinished) {
|
||||
if ev == nil {
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
a.requestCount++
|
||||
|
||||
size := int64(ev.EncodedDataLength)
|
||||
a.bytesTotal += size
|
||||
if size > a.heaviestBytes {
|
||||
a.heaviestBytes = size
|
||||
a.heaviestURL = a.requestURLByID[ev.RequestID]
|
||||
}
|
||||
}
|
||||
|
||||
// onLoadingFailed records a request that failed to complete.
|
||||
func (a *networkAggregate) onLoadingFailed(ev *network.EventLoadingFailed) {
|
||||
if ev == nil {
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
a.requestCount++
|
||||
a.failedCount++
|
||||
}
|
||||
|
||||
func (a *networkAggregate) snapshot() networkStats {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
return networkStats{
|
||||
requestCount: a.requestCount,
|
||||
bytesTotal: a.bytesTotal,
|
||||
failedCount: a.failedCount,
|
||||
uniqueOrigins: int64(len(a.origins)),
|
||||
heaviestURL: a.heaviestURL,
|
||||
heaviestBytes: a.heaviestBytes,
|
||||
}
|
||||
}
|
||||
|
||||
// originOf returns the scheme://host of rawURL, or an empty string when it has
|
||||
// no host (for example data: or file: URLs).
|
||||
func originOf(rawURL string) string {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return parsed.Scheme + "://" + parsed.Host
|
||||
}
|
||||
100
pkg/modules/chromium/network_aggregate_test.go
Normal file
100
pkg/modules/chromium/network_aggregate_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/chromedp/cdproto/network"
|
||||
)
|
||||
|
||||
func TestOriginOf(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{"https://example.com/path?q=1", "https://example.com"},
|
||||
{"http://cdn.example.com:8080/a.js", "http://cdn.example.com:8080"},
|
||||
{"data:image/png;base64,AAAA", ""},
|
||||
{"file:///tmp/index.html", ""},
|
||||
{"not a url", ""},
|
||||
} {
|
||||
if got := originOf(tc.raw); got != tc.want {
|
||||
t.Errorf("originOf(%q) = %q, want %q", tc.raw, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkAggregate_Snapshot(t *testing.T) {
|
||||
a := newNetworkAggregate()
|
||||
|
||||
a.onResponseReceived(&network.EventResponseReceived{
|
||||
RequestID: "1",
|
||||
Response: &network.Response{URL: "https://example.com/a.js"},
|
||||
})
|
||||
a.onResponseReceived(&network.EventResponseReceived{
|
||||
RequestID: "2",
|
||||
Response: &network.Response{URL: "https://cdn.example.com/b.png"},
|
||||
})
|
||||
// Duplicate origin must not grow the set.
|
||||
a.onResponseReceived(&network.EventResponseReceived{
|
||||
RequestID: "3",
|
||||
Response: &network.Response{URL: "https://example.com/c.css"},
|
||||
})
|
||||
|
||||
a.onLoadingFinished(&network.EventLoadingFinished{RequestID: "1", EncodedDataLength: 100})
|
||||
a.onLoadingFinished(&network.EventLoadingFinished{RequestID: "2", EncodedDataLength: 900})
|
||||
a.onLoadingFailed(&network.EventLoadingFailed{RequestID: "3"})
|
||||
|
||||
got := a.snapshot()
|
||||
if got.requestCount != 3 {
|
||||
t.Errorf("requestCount = %d, want 3", got.requestCount)
|
||||
}
|
||||
if got.bytesTotal != 1000 {
|
||||
t.Errorf("bytesTotal = %d, want 1000", got.bytesTotal)
|
||||
}
|
||||
if got.failedCount != 1 {
|
||||
t.Errorf("failedCount = %d, want 1", got.failedCount)
|
||||
}
|
||||
if got.uniqueOrigins != 2 {
|
||||
t.Errorf("uniqueOrigins = %d, want 2", got.uniqueOrigins)
|
||||
}
|
||||
if got.heaviestBytes != 900 || got.heaviestURL != "https://cdn.example.com/b.png" {
|
||||
t.Errorf("heaviest = (%q, %d), want (%q, 900)", got.heaviestURL, got.heaviestBytes, "https://cdn.example.com/b.png")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkAggregate_OriginCap(t *testing.T) {
|
||||
a := newNetworkAggregate()
|
||||
for i := range maxTrackedOrigins + 50 {
|
||||
a.onResponseReceived(&network.EventResponseReceived{
|
||||
RequestID: network.RequestID(fmt.Sprintf("r%d", i)),
|
||||
Response: &network.Response{URL: fmt.Sprintf("https://host%d.example.com/x", i)},
|
||||
})
|
||||
}
|
||||
if got := a.snapshot().uniqueOrigins; got != maxTrackedOrigins {
|
||||
t.Errorf("uniqueOrigins = %d, want %d (capped)", got, maxTrackedOrigins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkAggregate_ConcurrentSafe(t *testing.T) {
|
||||
a := newNetworkAggregate()
|
||||
var wg sync.WaitGroup
|
||||
for i := range 100 {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
id := network.RequestID(fmt.Sprintf("r%d", i))
|
||||
a.onResponseReceived(&network.EventResponseReceived{
|
||||
RequestID: id,
|
||||
Response: &network.Response{URL: fmt.Sprintf("https://host%d.example.com/x", i)},
|
||||
})
|
||||
a.onLoadingFinished(&network.EventLoadingFinished{RequestID: id, EncodedDataLength: 10})
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if got := a.snapshot().requestCount; got != 100 {
|
||||
t.Errorf("requestCount = %d, want 100", got)
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,12 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"golang.org/x/net/http/httpproxy"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
@@ -43,6 +45,14 @@ type pinningProxy struct {
|
||||
// allow-list opt-in). Tests may override it.
|
||||
dialBypass func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||
|
||||
// upstreamProxy resolves the upstream (corporate) proxy for a
|
||||
// destination URL from the standard proxy environment variables, or
|
||||
// returns a nil URL to connect directly. It is nil unless the operator
|
||||
// opted into proxy-environment honoring. When set, the pinning proxy
|
||||
// performs the authenticated proxy handshake that Chromium cannot. See
|
||||
// https://github.com/gotenberg/gotenberg/issues/1592.
|
||||
upstreamProxy func(*url.URL) (*url.URL, error)
|
||||
|
||||
listener net.Listener
|
||||
server *http.Server
|
||||
wg sync.WaitGroup
|
||||
@@ -57,8 +67,8 @@ type pinningProxy struct {
|
||||
// [gotenberg.DecideOutbound] on every request the proxy sees, so
|
||||
// Chromium inherits whatever posture the operator selected. The
|
||||
// returned proxy is not yet listening; call Start.
|
||||
func newPinningProxy(allowList, denyList []*regexp2.Regexp, denyPrivateIPs, denyPublicIPs bool) *pinningProxy {
|
||||
return &pinningProxy{
|
||||
func newPinningProxy(allowList, denyList []*regexp2.Regexp, denyPrivateIPs, denyPublicIPs, enableEnvironmentProxy bool) *pinningProxy {
|
||||
p := &pinningProxy{
|
||||
allowList: allowList,
|
||||
denyList: denyList,
|
||||
decide: func(ctx context.Context, rawURL string, allow, deny []*regexp2.Regexp, deadline time.Time) (gotenberg.OutboundDecision, error) {
|
||||
@@ -73,6 +83,14 @@ func newPinningProxy(allowList, denyList []*regexp2.Regexp, denyPrivateIPs, deny
|
||||
return dialer.DialContext(ctx, network, addr)
|
||||
},
|
||||
}
|
||||
|
||||
if enableEnvironmentProxy {
|
||||
// Honor the standard proxy environment variables, credentials
|
||||
// included. httpproxy reads the environment now and applies NO_PROXY.
|
||||
p.upstreamProxy = httpproxy.FromEnvironment().ProxyFunc()
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// Start binds the proxy to 127.0.0.1 on an ephemeral port and serves in a
|
||||
@@ -188,8 +206,24 @@ func (p *pinningProxy) handleConnect(w http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// When the operator routes egress through an authenticated proxy,
|
||||
// Chromium cannot supply the credentials itself, so the pinning proxy
|
||||
// performs the CONNECT (and authentication) upstream. The decision above
|
||||
// still gated the destination through the allow/deny and IP-class rules.
|
||||
var proxyURL *url.URL
|
||||
if p.upstreamProxy != nil {
|
||||
proxyURL, err = p.upstreamProxy(&url.URL{Scheme: "https", Host: req.Host})
|
||||
if err != nil {
|
||||
p.logger.WarnContext(req.Context(), fmt.Sprintf("resolve upstream proxy for '%s': %s", req.Host, err))
|
||||
http.Error(w, "upstream proxy error", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var upstream net.Conn
|
||||
switch {
|
||||
case proxyURL != nil:
|
||||
upstream, err = p.dialThroughUpstreamProxy(req.Context(), proxyURL, req.Host)
|
||||
case decision.Bypass:
|
||||
upstream, err = p.dialBypass(req.Context(), "tcp", req.Host)
|
||||
case len(decision.Pinned) > 0:
|
||||
@@ -275,17 +309,35 @@ func (p *pinningProxy) handleForward(w http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var proxyURL *url.URL
|
||||
if p.upstreamProxy != nil {
|
||||
proxyURL, err = p.upstreamProxy(req.URL)
|
||||
if err != nil {
|
||||
p.logger.WarnContext(req.Context(), fmt.Sprintf("resolve upstream proxy for '%s': %s", req.URL.Redacted(), err))
|
||||
http.Error(w, "upstream proxy error", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
outReq := req.Clone(req.Context())
|
||||
outReq.RequestURI = ""
|
||||
stripHopByHopHeaders(outReq.Header)
|
||||
|
||||
// Build a fresh transport per request. The decision contains the pinned
|
||||
// IPs to dial; reusing a transport across requests would leak the
|
||||
// decision's closure across unrelated targets.
|
||||
transport := &http.Transport{
|
||||
// Build a fresh transport per request. The decision contains the
|
||||
// pinned IPs to dial; reusing a transport across requests would
|
||||
// leak the decision's closure across unrelated targets.
|
||||
DisableKeepAlives: true,
|
||||
Proxy: nil,
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
}
|
||||
if proxyURL != nil {
|
||||
// The upstream proxy owns DNS and egress; Go adds Proxy-Authorization
|
||||
// from the URL's credentials. The decision above already gated the
|
||||
// destination, and dialBypass dials the proxy host directly.
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
transport.DialContext = p.dialBypass
|
||||
} else {
|
||||
transport.Proxy = nil
|
||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
_, port, splitErr := net.SplitHostPort(addr)
|
||||
if splitErr != nil {
|
||||
return nil, fmt.Errorf("split forward addr %q: %w", addr, splitErr)
|
||||
@@ -298,7 +350,7 @@ func (p *pinningProxy) handleForward(w http.ResponseWriter, req *http.Request) {
|
||||
default:
|
||||
return nil, errors.New("no pinned addresses and not bypassed")
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
defer transport.CloseIdleConnections()
|
||||
|
||||
@@ -364,3 +416,11 @@ func isClientCancellation(ctx context.Context, err error) bool {
|
||||
}
|
||||
return ctx.Err() != nil
|
||||
}
|
||||
|
||||
// dialThroughUpstreamProxy tunnels to target through the upstream proxy,
|
||||
// letting [gotenberg.DialThroughProxy] perform the authenticated CONNECT that
|
||||
// Chromium cannot. dialBypass dials the proxy itself and is overridable in
|
||||
// tests. See https://github.com/gotenberg/gotenberg/issues/1592.
|
||||
func (p *pinningProxy) dialThroughUpstreamProxy(ctx context.Context, proxyURL *url.URL, target string) (net.Conn, error) {
|
||||
return gotenberg.DialThroughProxy(ctx, proxyURL, target, p.dialBypass)
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ func TestPinningProxy_Forward_Pinned_Success(t *testing.T) {
|
||||
upstreamURL := mustParseURL(t, upstream.URL)
|
||||
|
||||
var decideCalls atomic.Int32
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p := newPinningProxy(nil, nil, false, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
decideCalls.Add(1)
|
||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||
@@ -151,7 +151,7 @@ func TestPinningProxy_Forward_Pinned_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPinningProxy_Forward_BlockedByDecide(t *testing.T) {
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p := newPinningProxy(nil, nil, false, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered)
|
||||
}
|
||||
@@ -187,7 +187,7 @@ func TestPinningProxy_Forward_Bypass(t *testing.T) {
|
||||
upstreamURL := mustParseURL(t, upstream.URL)
|
||||
|
||||
var bypassCalls atomic.Int32
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p := newPinningProxy(nil, nil, false, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.OutboundDecision{Bypass: true}, nil
|
||||
}
|
||||
@@ -236,7 +236,7 @@ func TestPinningProxy_Forward_StripsHopByHopHeaders(t *testing.T) {
|
||||
t.Cleanup(upstream.Close)
|
||||
upstreamURL := mustParseURL(t, upstream.URL)
|
||||
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p := newPinningProxy(nil, nil, false, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||
}
|
||||
@@ -276,7 +276,7 @@ func TestPinningProxy_Forward_StripsHopByHopHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPinningProxy_Forward_RejectsNonAbsoluteURL(t *testing.T) {
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p := newPinningProxy(nil, nil, false, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
t.Fatal("decide must not be called for malformed proxy request")
|
||||
return gotenberg.OutboundDecision{}, nil
|
||||
@@ -316,7 +316,7 @@ func TestPinningProxy_CONNECT_Pinned_Success(t *testing.T) {
|
||||
t.Cleanup(stop)
|
||||
|
||||
var decideCalls atomic.Int32
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p := newPinningProxy(nil, nil, false, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
decideCalls.Add(1)
|
||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||
@@ -386,7 +386,7 @@ func TestPinningProxy_CONNECT_Pinned_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPinningProxy_CONNECT_BlockedByDecide(t *testing.T) {
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p := newPinningProxy(nil, nil, false, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered)
|
||||
}
|
||||
@@ -445,7 +445,7 @@ func TestPinningProxy_DNSRebind_SingleResolution(t *testing.T) {
|
||||
return gotenberg.OutboundDecision{}, fmt.Errorf("rebind lookup: %w", gotenberg.ErrFiltered)
|
||||
}
|
||||
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p := newPinningProxy(nil, nil, false, false, false)
|
||||
p.decide = stubDecide
|
||||
p.dialPinned = func(_ context.Context, network string, addrs []netip.Addr, _ string) (net.Conn, error) {
|
||||
if len(addrs) != 1 || addrs[0].String() != "93.184.216.34" {
|
||||
@@ -488,7 +488,7 @@ func TestPinningProxy_DNSRebind_SingleResolution(t *testing.T) {
|
||||
// [TestPinningProxy_CONNECT_BlockedByDecide].
|
||||
func TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug(t *testing.T) {
|
||||
rec := &recordingHandler{}
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p := newPinningProxy(nil, nil, false, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
// Mimic the wrap chain produced by outbound.resolveHost when the
|
||||
// DNS lookup is canceled mid-flight by Chromium hanging up.
|
||||
@@ -547,7 +547,7 @@ func TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug(t *testing.T) {
|
||||
// HTTP forward requests aborted by the client must also log at debug.
|
||||
func TestPinningProxy_Forward_ClientCancellation_LoggedAtDebug(t *testing.T) {
|
||||
rec := &recordingHandler{}
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p := newPinningProxy(nil, nil, false, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.OutboundDecision{}, fmt.Errorf("validate host: %w", context.DeadlineExceeded)
|
||||
}
|
||||
@@ -596,7 +596,7 @@ func TestPinningProxy_Forward_ClientCancellation_LoggedAtDebug(t *testing.T) {
|
||||
// still surface at warn level so operators see real refusals.
|
||||
func TestPinningProxy_PolicyDenial_LoggedAtWarn(t *testing.T) {
|
||||
rec := &recordingHandler{}
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p := newPinningProxy(nil, nil, false, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.OutboundDecision{}, fmt.Errorf("denied: %w", gotenberg.ErrFiltered)
|
||||
}
|
||||
@@ -652,7 +652,7 @@ func TestPinningProxy_PolicyDenial_LoggedAtWarn(t *testing.T) {
|
||||
// [TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn].
|
||||
func TestPinningProxy_CONNECT_DialCancellation_LoggedAtDebug(t *testing.T) {
|
||||
rec := &recordingHandler{}
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p := newPinningProxy(nil, nil, false, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||
}
|
||||
@@ -713,7 +713,7 @@ func TestPinningProxy_CONNECT_DialCancellation_LoggedAtDebug(t *testing.T) {
|
||||
// must still warn so operators see real problems.
|
||||
func TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn(t *testing.T) {
|
||||
rec := &recordingHandler{}
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p := newPinningProxy(nil, nil, false, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||
}
|
||||
@@ -770,7 +770,7 @@ func TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn(t *testing.T) {
|
||||
// logs at debug, not warn. Genuine RoundTrip failures still warn.
|
||||
func TestPinningProxy_Forward_RoundTripCancellation_LoggedAtDebug(t *testing.T) {
|
||||
rec := &recordingHandler{}
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p := newPinningProxy(nil, nil, false, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||
}
|
||||
@@ -868,7 +868,7 @@ func TestIsClientCancellation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPinningProxy_StartTwice(t *testing.T) {
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p := newPinningProxy(nil, nil, false, false, false)
|
||||
err := p.Start(testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("first Start: %v", err)
|
||||
@@ -882,7 +882,7 @@ func TestPinningProxy_StartTwice(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPinningProxy_StopIdempotent(t *testing.T) {
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p := newPinningProxy(nil, nil, false, false, false)
|
||||
// Stop on a never-started proxy is a no-op.
|
||||
if err := p.Stop(testLogger()); err != nil {
|
||||
t.Fatalf("Stop on never-started proxy: %v", err)
|
||||
|
||||
76
pkg/modules/chromium/pinning_proxy_upstream_test.go
Normal file
76
pkg/modules/chromium/pinning_proxy_upstream_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
// TestPinningProxy_Forward_ThroughUpstreamProxy verifies that when the
|
||||
// operator opts into proxy-environment honoring, a plain HTTP request is
|
||||
// forwarded through the upstream (corporate) proxy with the credentials
|
||||
// Chromium cannot supply. See https://github.com/gotenberg/gotenberg/issues/1592.
|
||||
func TestPinningProxy_Forward_ThroughUpstreamProxy(t *testing.T) {
|
||||
var gotAuth atomic.Value
|
||||
gotAuth.Store("")
|
||||
|
||||
// Stand-in for the corporate proxy: an HTTP server that receives the
|
||||
// forwarded request and records the injected Proxy-Authorization.
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth.Store(r.Header.Get("Proxy-Authorization"))
|
||||
_, _ = fmt.Fprint(w, "via-corporate-proxy")
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
upstreamURL := mustParseURL(t, upstream.URL)
|
||||
upstreamURL.User = url.UserPassword("bob", "pw")
|
||||
|
||||
p := newPinningProxy(nil, nil, false, false, true)
|
||||
// Force every destination through our stub upstream proxy.
|
||||
p.upstreamProxy = func(_ *url.URL) (*url.URL, error) { return upstreamURL, nil }
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||
}
|
||||
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||
t.Fatal("dialPinned must not be called when routing through an upstream proxy")
|
||||
return nil, nil
|
||||
}
|
||||
proxyURL := newProxyForTest(t, p)
|
||||
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{Proxy: http.ProxyURL(mustParseURL(t, proxyURL))},
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Get("http://example.com/")
|
||||
if err != nil {
|
||||
t.Fatalf("GET via proxy: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
if string(body) != "via-corporate-proxy" {
|
||||
t.Fatalf("body = %q, want via-corporate-proxy", body)
|
||||
}
|
||||
|
||||
wantAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("bob:pw"))
|
||||
if got := gotAuth.Load().(string); got != wantAuth {
|
||||
t.Fatalf("upstream proxy saw Proxy-Authorization %q, want %q", got, wantAuth)
|
||||
}
|
||||
}
|
||||
@@ -446,7 +446,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
mode := pdfengines.FormDataPdfSplitMode(form, false)
|
||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
||||
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
||||
@@ -454,6 +454,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||
|
||||
var url string
|
||||
err := form.
|
||||
@@ -477,7 +478,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
return fmt.Errorf("validate stamp: %w", err)
|
||||
}
|
||||
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert URL to PDF: %w", err)
|
||||
}
|
||||
@@ -534,7 +535,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
mode := pdfengines.FormDataPdfSplitMode(form, false)
|
||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
||||
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
||||
@@ -542,6 +543,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||
|
||||
var inputPath string
|
||||
err := form.
|
||||
@@ -562,7 +564,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
|
||||
url := fmt.Sprintf("file://%s", inputPath)
|
||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert HTML to PDF: %w", err)
|
||||
}
|
||||
@@ -616,7 +618,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
mode := pdfengines.FormDataPdfSplitMode(form, false)
|
||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
||||
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
||||
@@ -624,6 +626,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||
|
||||
var (
|
||||
inputPath string
|
||||
@@ -653,7 +656,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
}
|
||||
|
||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert markdown to PDF: %w", err)
|
||||
}
|
||||
@@ -778,7 +781,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
|
||||
return fmt.Sprintf("file://%s", inputPath), nil
|
||||
}
|
||||
|
||||
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, embedPaths []string, embedsMetadata map[string]map[string]string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error {
|
||||
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, encrypt gotenberg.EncryptOptions, embedPaths []string, embedsMetadata map[string]map[string]string, facturX gotenberg.FacturX, facturxXmlPath string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error {
|
||||
outputPath := ctx.GeneratePath(".pdf")
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1130.
|
||||
filename := ctx.OutputFilename(outputPath)
|
||||
@@ -840,7 +843,17 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
||||
return fmt.Errorf("convert to PDF: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
|
||||
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = pdfengines.ValidatePdfEncryptCompat(encrypt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = pdfengines.ValidateFacturXCompat(facturX, facturxXmlPath, pdfFormats)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -865,6 +878,8 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
||||
return fmt.Errorf("rotate PDFs: %w", err)
|
||||
}
|
||||
|
||||
pdfFormats = pdfengines.FacturXPdfFormats(ctx, engine, facturX, pdfFormats, true, nil)
|
||||
|
||||
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert PDF(s): %w", err)
|
||||
@@ -887,7 +902,12 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
||||
return fmt.Errorf("set embeds metadata: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
|
||||
err = pdfengines.ApplyFacturXStub(ctx, engine, facturX, facturxXmlPath, convertOutputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply Factur-X: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.EncryptPdfStub(ctx, engine, encrypt, convertOutputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||
}
|
||||
|
||||
@@ -14,104 +14,169 @@ import (
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/chromedp"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func printToPdfActionFunc(logger *slog.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc {
|
||||
// resolvePdfOptions applies the cross-option constraints Chromium imposes
|
||||
// before printing.
|
||||
//
|
||||
// Chromium derives the PDF document outline from the tagged-PDF structure
|
||||
// tree, so [PdfOptions.GenerateDocumentOutline] produces no outline unless
|
||||
// tagged PDF is also generated. Requesting an outline therefore implies
|
||||
// tagged PDF. See https://github.com/gotenberg/gotenberg/issues/1579.
|
||||
func resolvePdfOptions(options PdfOptions) PdfOptions {
|
||||
if options.GenerateDocumentOutline {
|
||||
options.GenerateTaggedPdf = true
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
paperHeight := options.PaperHeight
|
||||
pageRanges := options.PageRanges
|
||||
|
||||
if options.SinglePage {
|
||||
logger.DebugContext(ctx, "single page PDF")
|
||||
|
||||
_, _, _, _, _, cssContentSize, err := page.GetLayoutMetrics().Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get layout metrics: %w", err)
|
||||
}
|
||||
|
||||
// There are 96 CSS pixels per inch.
|
||||
// See https://issues.chromium.org/issues/40267771#comment14.
|
||||
// We add top and bottom margins so that the content area
|
||||
// is large enough to fit the entire content.
|
||||
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
|
||||
pageRanges = "1" // little dirty hack to avoid leftovers.
|
||||
if options.GenerateDocumentOutline && !options.GenerateTaggedPdf {
|
||||
logger.DebugContext(ctx, "document outline requested, enabling tagged PDF because Chromium derives the outline from the structure tree")
|
||||
}
|
||||
|
||||
printToPdf := page.PrintToPDF().
|
||||
WithTransferMode(page.PrintToPDFTransferModeReturnAsStream).
|
||||
WithLandscape(options.Landscape).
|
||||
WithPrintBackground(options.PrintBackground).
|
||||
WithScale(options.Scale).
|
||||
WithPaperWidth(options.PaperWidth).
|
||||
WithPaperHeight(paperHeight).
|
||||
WithMarginTop(options.MarginTop).
|
||||
WithMarginBottom(options.MarginBottom).
|
||||
WithMarginLeft(options.MarginLeft).
|
||||
WithMarginRight(options.MarginRight).
|
||||
WithPageRanges(pageRanges).
|
||||
WithPreferCSSPageSize(options.PreferCssPageSize).
|
||||
WithGenerateDocumentOutline(options.GenerateDocumentOutline).
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1210.
|
||||
WithGenerateTaggedPDF(options.GenerateTaggedPdf)
|
||||
options = resolvePdfOptions(options)
|
||||
|
||||
hasCustomHeaderFooter := options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate ||
|
||||
options.FooterTemplate != DefaultPdfOptions().FooterTemplate
|
||||
// ctx is the chromedp task context, derived from context.Background(),
|
||||
// so the span is started under reqCtx to keep print_to_pdf in the
|
||||
// conversion trace instead of orphaning it into a new one.
|
||||
_, span := gotenberg.Tracer().Start(reqCtx, "chromium.print_to_pdf",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(printToPdfAttrs(options)...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
if !hasCustomHeaderFooter {
|
||||
logger.DebugContext(ctx, "no custom header nor footer")
|
||||
err := func() error {
|
||||
paperHeight := options.PaperHeight
|
||||
pageRanges := options.PageRanges
|
||||
|
||||
printToPdf = printToPdf.WithDisplayHeaderFooter(false)
|
||||
if options.SinglePage {
|
||||
logger.DebugContext(ctx, "single page PDF")
|
||||
|
||||
_, _, _, _, _, cssContentSize, err := page.GetLayoutMetrics().Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get layout metrics: %w", err)
|
||||
}
|
||||
|
||||
// There are 96 CSS pixels per inch.
|
||||
// See https://issues.chromium.org/issues/40267771#comment14.
|
||||
// We add top and bottom margins so that the content area
|
||||
// is large enough to fit the entire content.
|
||||
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
|
||||
pageRanges = "1" // little dirty hack to avoid leftovers.
|
||||
}
|
||||
|
||||
printToPdf := page.PrintToPDF().
|
||||
WithTransferMode(page.PrintToPDFTransferModeReturnAsStream).
|
||||
WithLandscape(options.Landscape).
|
||||
WithPrintBackground(options.PrintBackground).
|
||||
WithScale(options.Scale).
|
||||
WithPaperWidth(options.PaperWidth).
|
||||
WithPaperHeight(paperHeight).
|
||||
WithMarginTop(options.MarginTop).
|
||||
WithMarginBottom(options.MarginBottom).
|
||||
WithMarginLeft(options.MarginLeft).
|
||||
WithMarginRight(options.MarginRight).
|
||||
WithPageRanges(pageRanges).
|
||||
WithPreferCSSPageSize(options.PreferCssPageSize).
|
||||
WithGenerateDocumentOutline(options.GenerateDocumentOutline).
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1210.
|
||||
WithGenerateTaggedPDF(options.GenerateTaggedPdf)
|
||||
|
||||
hasCustomHeaderFooter := options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate ||
|
||||
options.FooterTemplate != DefaultPdfOptions().FooterTemplate
|
||||
|
||||
if !hasCustomHeaderFooter {
|
||||
logger.DebugContext(ctx, "no custom header nor footer")
|
||||
|
||||
printToPdf = printToPdf.WithDisplayHeaderFooter(false)
|
||||
} else {
|
||||
logger.DebugContext(ctx, "with custom header and/or footer")
|
||||
|
||||
printToPdf = printToPdf.
|
||||
WithDisplayHeaderFooter(true).
|
||||
WithHeaderTemplate(options.HeaderTemplate).
|
||||
WithFooterTemplate(options.FooterTemplate)
|
||||
}
|
||||
|
||||
logger.DebugContext(ctx, fmt.Sprintf("print to PDF with: %+v", printToPdf))
|
||||
|
||||
_, stream, err := printToPdf.Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("print to PDF: %w", err)
|
||||
}
|
||||
|
||||
reader := &streamReader{
|
||||
ctx: ctx,
|
||||
handle: stream,
|
||||
r: nil,
|
||||
pos: 0,
|
||||
eof: false,
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err = reader.Close()
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, fmt.Sprintf("close reader: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open output path: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err = file.Close()
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, fmt.Sprintf("close output path: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
buffer := bufio.NewReader(reader)
|
||||
|
||||
_, err = buffer.WriteTo(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write result to output path: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
} else {
|
||||
logger.DebugContext(ctx, "with custom header and/or footer")
|
||||
|
||||
printToPdf = printToPdf.
|
||||
WithDisplayHeaderFooter(true).
|
||||
WithHeaderTemplate(options.HeaderTemplate).
|
||||
WithFooterTemplate(options.FooterTemplate)
|
||||
span.SetStatus(codes.Ok, "")
|
||||
}
|
||||
|
||||
logger.DebugContext(ctx, fmt.Sprintf("print to PDF with: %+v", printToPdf))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, stream, err := printToPdf.Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("print to PDF: %w", err)
|
||||
}
|
||||
|
||||
reader := &streamReader{
|
||||
ctx: ctx,
|
||||
handle: stream,
|
||||
r: nil,
|
||||
pos: 0,
|
||||
eof: false,
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err = reader.Close()
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, fmt.Sprintf("close reader: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open output path: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err = file.Close()
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, fmt.Sprintf("close output path: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
buffer := bufio.NewReader(reader)
|
||||
|
||||
_, err = buffer.WriteTo(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write result to output path: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
// printToPdfAttrs derives bounded, low-cardinality attributes from the print
|
||||
// options. Raw header/footer templates and page ranges are reduced to booleans
|
||||
// to avoid leaking document content and exploding cardinality.
|
||||
func printToPdfAttrs(options PdfOptions) []attribute.KeyValue {
|
||||
return []attribute.KeyValue{
|
||||
attribute.Bool("gotenberg.chromium.print.landscape", options.Landscape),
|
||||
attribute.Bool("gotenberg.chromium.print.print_background", options.PrintBackground),
|
||||
attribute.Float64("gotenberg.chromium.print.scale", options.Scale),
|
||||
attribute.Float64("gotenberg.chromium.print.paper_width", options.PaperWidth),
|
||||
attribute.Float64("gotenberg.chromium.print.paper_height", options.PaperHeight),
|
||||
attribute.Bool("gotenberg.chromium.print.single_page", options.SinglePage),
|
||||
attribute.Bool("gotenberg.chromium.print.prefer_css_page_size", options.PreferCssPageSize),
|
||||
attribute.Bool("gotenberg.chromium.print.generate_tagged_pdf", options.GenerateTaggedPdf),
|
||||
attribute.Bool("gotenberg.chromium.print.has_page_ranges", options.PageRanges != ""),
|
||||
attribute.Bool("gotenberg.chromium.print.has_header", options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate),
|
||||
attribute.Bool("gotenberg.chromium.print.has_footer", options.FooterTemplate != DefaultPdfOptions().FooterTemplate),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
52
pkg/modules/chromium/tasks_test.go
Normal file
52
pkg/modules/chromium/tasks_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package chromium
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolvePdfOptions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
generateOutline bool
|
||||
generateTaggedIn bool
|
||||
generateTaggedWant bool
|
||||
}{
|
||||
{
|
||||
scenario: "outline requested forces tagged PDF",
|
||||
generateOutline: true,
|
||||
generateTaggedIn: false,
|
||||
generateTaggedWant: true,
|
||||
},
|
||||
{
|
||||
scenario: "outline requested keeps tagged PDF on",
|
||||
generateOutline: true,
|
||||
generateTaggedIn: true,
|
||||
generateTaggedWant: true,
|
||||
},
|
||||
{
|
||||
scenario: "no outline leaves tagged PDF off",
|
||||
generateOutline: false,
|
||||
generateTaggedIn: false,
|
||||
generateTaggedWant: false,
|
||||
},
|
||||
{
|
||||
scenario: "no outline keeps tagged PDF on",
|
||||
generateOutline: false,
|
||||
generateTaggedIn: true,
|
||||
generateTaggedWant: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
options := DefaultPdfOptions()
|
||||
options.GenerateDocumentOutline = tc.generateOutline
|
||||
options.GenerateTaggedPdf = tc.generateTaggedIn
|
||||
|
||||
got := resolvePdfOptions(options)
|
||||
|
||||
if got.GenerateTaggedPdf != tc.generateTaggedWant {
|
||||
t.Errorf("expected GenerateTaggedPdf=%t, got %t", tc.generateTaggedWant, got.GenerateTaggedPdf)
|
||||
}
|
||||
if got.GenerateDocumentOutline != tc.generateOutline {
|
||||
t.Errorf("expected GenerateDocumentOutline=%t, got %t", tc.generateOutline, got.GenerateDocumentOutline)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
37
pkg/modules/chromium/version_test.go
Normal file
37
pkg/modules/chromium/version_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestChromiumDetectVersion(t *testing.T) {
|
||||
t.Run("prefers the build-time version without executing Chromium", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "chromium"), []byte("Chromium 146.0.7680.80\n"), 0o600); err != nil {
|
||||
t.Fatalf("write version file: %v", err)
|
||||
}
|
||||
t.Setenv(gotenberg.BuildVersionsDirPathEnvVar, dir)
|
||||
|
||||
// A bogus binPath would error if executed, so a correct result proves
|
||||
// the build-time file is used instead of running Chromium.
|
||||
mod := &Chromium{args: browserArguments{binPath: "/nonexistent/chromium"}}
|
||||
if got := mod.Debug()["version"]; got != "Chromium 146.0.7680.80" {
|
||||
t.Errorf("Debug()[version] = %v, want the build-time value", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("falls back to executing Chromium when no build-time version", func(t *testing.T) {
|
||||
t.Setenv(gotenberg.BuildVersionsDirPathEnvVar, "")
|
||||
|
||||
// With no build-time file and a bogus binPath, the exec fallback runs
|
||||
// and records its error rather than a build-time value.
|
||||
mod := &Chromium{args: browserArguments{binPath: "/nonexistent/chromium"}}
|
||||
if got := mod.Debug()["version"]; got == "Chromium 146.0.7680.80" {
|
||||
t.Errorf("Debug()[version] = %v, expected the exec fallback", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -10,8 +10,10 @@ import (
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
@@ -159,6 +161,9 @@ func buildExifToolWriteArgs(metadata map[string]any) ([]string, error) {
|
||||
// [gotenberg.PdfEngine] interface.
|
||||
type ExifTool struct {
|
||||
binPath string
|
||||
|
||||
version string
|
||||
versionOnce sync.Once
|
||||
}
|
||||
|
||||
// Descriptor returns [ExifTool]'s module descriptor.
|
||||
@@ -193,26 +198,53 @@ func (engine *ExifTool) Validate() error {
|
||||
|
||||
// Debug returns additional debug data.
|
||||
func (engine *ExifTool) Debug() map[string]any {
|
||||
debug := make(map[string]any)
|
||||
return map[string]any{"version": engine.detectVersion()}
|
||||
}
|
||||
|
||||
cmd := exec.Command(engine.binPath, "-ver") //nolint:gosec
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
// detectVersion resolves the ExifTool version once, preferring the value
|
||||
// captured at image build time so it never spawns ExifTool at runtime. It falls
|
||||
// back to running exiftool -ver for local or non-Docker builds.
|
||||
func (engine *ExifTool) detectVersion() string {
|
||||
engine.versionOnce.Do(func() {
|
||||
if v, ok := gotenberg.BuildVersion("exiftool"); ok {
|
||||
engine.version = v
|
||||
return
|
||||
}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
debug["version"] = err.Error()
|
||||
return debug
|
||||
cmd := exec.Command(engine.binPath, "-ver") //nolint:gosec
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
engine.version = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
engine.version = strings.TrimSpace(string(output))
|
||||
})
|
||||
|
||||
return engine.version
|
||||
}
|
||||
|
||||
// spanAttrs returns the client-span attributes for an ExifTool invocation: the
|
||||
// server address and the ExifTool version, plus any extra attributes. The
|
||||
// version rides on every span so a trace records which ExifTool ran the
|
||||
// operation.
|
||||
func (engine *ExifTool) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue {
|
||||
attrs := make([]attribute.KeyValue, 0, 2+len(extra))
|
||||
attrs = append(attrs, semconv.ServerAddress(engine.binPath))
|
||||
if v := engine.detectVersion(); v != "" {
|
||||
attrs = append(attrs, attribute.String("gotenberg.exiftool.version", v))
|
||||
}
|
||||
|
||||
debug["version"] = strings.TrimSpace(string(output))
|
||||
return debug
|
||||
return append(attrs, extra...)
|
||||
}
|
||||
|
||||
// Merge is not available in this implementation.
|
||||
func (engine *ExifTool) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Merge",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -226,7 +258,7 @@ func (engine *ExifTool) Merge(ctx context.Context, logger *slog.Logger, inputPat
|
||||
func (engine *ExifTool) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Split",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -240,7 +272,7 @@ func (engine *ExifTool) Split(ctx context.Context, logger *slog.Logger, mode got
|
||||
func (engine *ExifTool) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Flatten",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -254,7 +286,7 @@ func (engine *ExifTool) Flatten(ctx context.Context, logger *slog.Logger, inputP
|
||||
func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Convert",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -269,7 +301,7 @@ func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, format
|
||||
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.ReadMetadata",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -326,7 +358,7 @@ func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *slog.Logger, i
|
||||
func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.WriteMetadata",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -371,7 +403,7 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *slog.Logger,
|
||||
func (engine *ExifTool) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.PageCount",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -424,7 +456,7 @@ func (engine *ExifTool) PageCount(ctx context.Context, logger *slog.Logger, inpu
|
||||
func (engine *ExifTool) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.WriteBookmarks",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -438,7 +470,7 @@ func (engine *ExifTool) WriteBookmarks(ctx context.Context, logger *slog.Logger,
|
||||
func (engine *ExifTool) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.ReadBookmarks",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -449,10 +481,10 @@ func (engine *ExifTool) ReadBookmarks(ctx context.Context, logger *slog.Logger,
|
||||
}
|
||||
|
||||
// Encrypt is not available in this implementation.
|
||||
func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
|
||||
func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Encrypt",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -466,7 +498,7 @@ func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputP
|
||||
func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.EmbedFiles",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -480,7 +512,7 @@ func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *slog.Logger, fil
|
||||
func (engine *ExifTool) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Watermark",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -494,7 +526,7 @@ func (engine *ExifTool) Watermark(ctx context.Context, logger *slog.Logger, inpu
|
||||
func (engine *ExifTool) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Stamp",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -508,7 +540,7 @@ func (engine *ExifTool) Stamp(ctx context.Context, logger *slog.Logger, inputPat
|
||||
func (engine *ExifTool) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Rotate",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -523,6 +555,16 @@ func (engine *ExifTool) EmbedFilesMetadata(ctx context.Context, logger *slog.Log
|
||||
return fmt.Errorf("set embeds metadata with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// InjectFacturXXMP is not available in this implementation.
|
||||
func (engine *ExifTool) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error {
|
||||
return fmt.Errorf("inject Factur-X XMP with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// ReadPdfAConformance is not available in this implementation.
|
||||
func (engine *ExifTool) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
|
||||
return "", "", fmt.Errorf("read PDF/A conformance with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*ExifTool)(nil)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -36,7 +37,7 @@ var (
|
||||
ErrUnoException = errors.New("uno exception")
|
||||
|
||||
// ErrRuntimeException happens when unoconverter returns exit code 6.
|
||||
ErrRuntimeException = errors.New("uno exception")
|
||||
ErrRuntimeException = errors.New("runtime exception")
|
||||
|
||||
// ErrCoreDumped happens randomly; sometimes a conversion will work as
|
||||
// expected, and some other time the same conversion will fail.
|
||||
@@ -53,11 +54,15 @@ type Api struct {
|
||||
libreOffice libreOffice
|
||||
supervisor gotenberg.ProcessSupervisor
|
||||
|
||||
version string
|
||||
versionOnce sync.Once
|
||||
|
||||
reqsCounter metric.Int64Counter
|
||||
errsCounter metric.Int64Counter
|
||||
conversionDurationCounter metric.Float64Histogram
|
||||
queueWaitDurationCounter metric.Float64Histogram
|
||||
pdfOutputSizeCounter metric.Int64Histogram
|
||||
coreDumpedRetriesCounter metric.Int64Counter
|
||||
}
|
||||
|
||||
// Options gathers available options when converting a document to PDF.
|
||||
@@ -331,6 +336,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
||||
fs.StringSlice("libreoffice-deny-list", []string{}, "Set the denied URLs for LibreOffice outbound fetches using regular expressions - supports multiple values")
|
||||
fs.Bool("libreoffice-deny-private-ips", false, "Reject LibreOffice outbound URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted documents to mitigate SSRF against internal services")
|
||||
fs.Bool("libreoffice-deny-public-ips", false, "Reject LibreOffice outbound URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent outbound traffic from leaving a private network")
|
||||
fs.Bool("libreoffice-enable-environment-proxy", false, "Route LibreOffice outbound fetches through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials")
|
||||
|
||||
return fs
|
||||
}(),
|
||||
@@ -358,10 +364,11 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
||||
unoBinPath: unoBinPath,
|
||||
startTimeout: flags.MustDuration("libreoffice-start-timeout"),
|
||||
proxyOptions: outboundProxyOptions{
|
||||
allowList: flags.MustRegexpSlice("libreoffice-allow-list"),
|
||||
denyList: flags.MustRegexpSlice("libreoffice-deny-list"),
|
||||
denyPrivateIPs: flags.MustBool("libreoffice-deny-private-ips"),
|
||||
denyPublicIPs: flags.MustBool("libreoffice-deny-public-ips"),
|
||||
allowList: flags.MustRegexpSlice("libreoffice-allow-list"),
|
||||
denyList: flags.MustRegexpSlice("libreoffice-deny-list"),
|
||||
denyPrivateIPs: flags.MustBool("libreoffice-deny-private-ips"),
|
||||
denyPublicIPs: flags.MustBool("libreoffice-deny-public-ips"),
|
||||
enableEnvironmentProxy: flags.MustBool("libreoffice-enable-environment-proxy"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -370,7 +377,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
||||
|
||||
// Process.
|
||||
a.libreOffice = newLibreOfficeProcess(a.args)
|
||||
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"), 1, flags.MustDuration("libreoffice-idle-shutdown-timeout"))
|
||||
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, "libreoffice", a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"), 1, flags.MustDuration("libreoffice-idle-shutdown-timeout"))
|
||||
|
||||
// Metrics.
|
||||
meter := gotenberg.Meter()
|
||||
@@ -468,6 +475,15 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
||||
return fmt.Errorf("create libreoffice.pdf.output.size histogram: %w", err)
|
||||
}
|
||||
|
||||
a.coreDumpedRetriesCounter, err = meter.Int64Counter(
|
||||
"libreoffice.conversion.retries.total",
|
||||
metric.WithDescription("Total number of LibreOffice conversion retries after a core dump"),
|
||||
metric.WithUnit("{retry}"),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create libreoffice.conversion.retries.total counter: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -477,12 +493,12 @@ func (a *Api) Validate() error {
|
||||
|
||||
_, statErr := os.Stat(a.args.binPath)
|
||||
if os.IsNotExist(statErr) {
|
||||
err = errors.Join(err, fmt.Errorf("LibreOffice binary path does not exist: %w", statErr))
|
||||
err = errors.Join(err, fmt.Errorf("LibreOffice binary does not exist at %q; check the LIBREOFFICE_BIN_PATH environment variable: %w", a.args.binPath, statErr))
|
||||
}
|
||||
|
||||
_, statErr = os.Stat(a.args.unoBinPath)
|
||||
if os.IsNotExist(statErr) {
|
||||
err = errors.Join(err, fmt.Errorf("unoconverter binary path does not exist: %w", statErr))
|
||||
err = errors.Join(err, fmt.Errorf("unoconverter binary does not exist at %q; check the UNOCONVERTER_BIN_PATH environment variable: %w", a.args.unoBinPath, statErr))
|
||||
}
|
||||
|
||||
return err
|
||||
@@ -530,19 +546,46 @@ func (a *Api) Stop(ctx context.Context) error {
|
||||
|
||||
// Debug returns additional debug data.
|
||||
func (a *Api) Debug() map[string]any {
|
||||
debug := make(map[string]any)
|
||||
return map[string]any{"version": a.detectVersion()}
|
||||
}
|
||||
|
||||
cmd := exec.Command(a.args.binPath, "--version") //nolint:gosec
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
// detectVersion resolves the LibreOffice version once, preferring the value
|
||||
// captured at image build time so it never spawns LibreOffice at runtime. It
|
||||
// falls back to running soffice --version for local or non-Docker builds.
|
||||
func (a *Api) detectVersion() string {
|
||||
a.versionOnce.Do(func() {
|
||||
if v, ok := gotenberg.BuildVersion("libreoffice-api"); ok {
|
||||
a.version = v
|
||||
return
|
||||
}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
debug["version"] = err.Error()
|
||||
return debug
|
||||
cmd := exec.Command(a.args.binPath, "--version") //nolint:gosec
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
a.version = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
a.version = strings.TrimSpace(string(output))
|
||||
})
|
||||
|
||||
return a.version
|
||||
}
|
||||
|
||||
// spanAttrs returns the client-span attributes for a LibreOffice invocation:
|
||||
// the server address and the LibreOffice version, plus any extra attributes.
|
||||
// The version rides on every conversion span so a trace records which
|
||||
// LibreOffice rendered the document.
|
||||
func (a *Api) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue {
|
||||
attrs := make([]attribute.KeyValue, 0, 2+len(extra))
|
||||
attrs = append(attrs, semconv.ServerAddress(a.args.binPath))
|
||||
if v := a.detectVersion(); v != "" {
|
||||
attrs = append(attrs, attribute.String("gotenberg.libreoffice.version", v))
|
||||
}
|
||||
|
||||
debug["version"] = strings.TrimSpace(string(output))
|
||||
return debug
|
||||
return append(attrs, extra...)
|
||||
}
|
||||
|
||||
// Metrics returns the metrics.
|
||||
@@ -618,76 +661,123 @@ func (a *Api) LibreOffice() (Uno, error) {
|
||||
func (a *Api) Pdf(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "libreoffice.Pdf",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(a.args.binPath)),
|
||||
trace.WithAttributes(a.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
start := time.Now()
|
||||
var conversionStart time.Time
|
||||
span.SetAttributes(
|
||||
attribute.Int64("gotenberg.queue.depth_at_arrival", a.supervisor.ReqQueueSize()),
|
||||
attribute.Int64("gotenberg.conversions_since_last_restart", a.supervisor.ConversionsSinceRestart()),
|
||||
)
|
||||
span.SetAttributes(conversionRequestAttributes(inputPath, options)...)
|
||||
|
||||
err := a.supervisor.Run(ctx, logger, func() error {
|
||||
conversionStart = time.Now()
|
||||
return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options)
|
||||
})
|
||||
// ErrCoreDumped happens randomly (https://github.com/gotenberg/gotenberg/issues/639);
|
||||
// retry the conversion, but cap the retries so a permanently failing
|
||||
// document cannot loop forever. Each attempt records its own metrics.
|
||||
const maxCoreDumpedRetries = 10
|
||||
|
||||
// Determine status and error reason.
|
||||
status := "success"
|
||||
reason := ""
|
||||
var err error
|
||||
var reason string
|
||||
for attempt := 0; ; attempt++ {
|
||||
start := time.Now()
|
||||
var conversionStart time.Time
|
||||
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
status = "timeout"
|
||||
reason = "timeout"
|
||||
case errors.Is(err, context.Canceled):
|
||||
err = a.supervisor.Run(ctx, logger, func() error {
|
||||
conversionStart = time.Now()
|
||||
return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options)
|
||||
})
|
||||
|
||||
// Determine status and error reason.
|
||||
status := "success"
|
||||
reason = ""
|
||||
|
||||
if err != nil {
|
||||
status = "error"
|
||||
reason = "context_cancelled"
|
||||
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) || errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
|
||||
status = "error"
|
||||
reason = "libreoffice_unavailable"
|
||||
default:
|
||||
status = "error"
|
||||
reason = "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Record metrics.
|
||||
attrs := metric.WithAttributes(attribute.String("status", status))
|
||||
a.reqsCounter.Add(ctx, 1, attrs)
|
||||
|
||||
if reason != "" {
|
||||
a.errsCounter.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
|
||||
}
|
||||
|
||||
if !conversionStart.IsZero() {
|
||||
queueWait := conversionStart.Sub(start).Seconds()
|
||||
a.queueWaitDurationCounter.Record(ctx, queueWait, attrs)
|
||||
|
||||
conversionDuration := time.Since(conversionStart).Seconds()
|
||||
a.conversionDurationCounter.Record(ctx, conversionDuration, attrs)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
stat, statErr := os.Stat(outputPath)
|
||||
if statErr == nil {
|
||||
a.pdfOutputSizeCounter.Record(ctx, stat.Size(), attrs)
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
status = "timeout"
|
||||
}
|
||||
reason = libreofficeErrorType(err)
|
||||
}
|
||||
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/639.
|
||||
if errors.Is(err, ErrCoreDumped) {
|
||||
logger.DebugContext(ctx, fmt.Sprintf("got a '%s' error, retry conversion", err))
|
||||
return a.Pdf(ctx, logger, inputPath, outputPath, options)
|
||||
// Record metrics for this attempt.
|
||||
attrs := metric.WithAttributes(attribute.String("status", status))
|
||||
a.reqsCounter.Add(ctx, 1, attrs)
|
||||
|
||||
if reason != "" {
|
||||
a.errsCounter.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
|
||||
}
|
||||
|
||||
if !conversionStart.IsZero() {
|
||||
queueWait := conversionStart.Sub(start).Seconds()
|
||||
a.queueWaitDurationCounter.Record(ctx, queueWait, attrs)
|
||||
|
||||
conversionDuration := time.Since(conversionStart).Seconds()
|
||||
a.conversionDurationCounter.Record(ctx, conversionDuration, attrs)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
stat, statErr := os.Stat(outputPath)
|
||||
if statErr == nil {
|
||||
a.pdfOutputSizeCounter.Record(ctx, stat.Size(), attrs)
|
||||
span.SetAttributes(attribute.Int64("gotenberg.conversion.output.bytes", stat.Size()))
|
||||
}
|
||||
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrCoreDumped) && attempt < maxCoreDumpedRetries {
|
||||
logger.DebugContext(ctx, fmt.Sprintf("got a '%s' error, retry conversion (attempt %d)", err, attempt+1))
|
||||
span.AddEvent("conversion.retry", trace.WithAttributes(
|
||||
attribute.Int("attempt", attempt+1),
|
||||
))
|
||||
a.coreDumpedRetriesCounter.Add(ctx, 1)
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
gotenberg.SpanErrorType(span, reason)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return fmt.Errorf("supervisor run task: %w", err)
|
||||
}
|
||||
|
||||
// conversionRequestAttributes derives low-cardinality attributes describing the
|
||||
// requested conversion: the input document size and the requested PDF format
|
||||
// options.
|
||||
func conversionRequestAttributes(inputPath string, options Options) []attribute.KeyValue {
|
||||
attrs := []attribute.KeyValue{
|
||||
attribute.String("gotenberg.libreoffice.pdf_a", options.PdfFormats.PdfA),
|
||||
attribute.Bool("gotenberg.libreoffice.pdf_ua", options.PdfFormats.PdfUa),
|
||||
attribute.Bool("gotenberg.conversion.landscape", options.Landscape),
|
||||
attribute.Bool("gotenberg.conversion.has_page_ranges", options.PageRanges != ""),
|
||||
}
|
||||
|
||||
if info, err := os.Stat(inputPath); err == nil {
|
||||
attrs = append(attrs, attribute.Int64("gotenberg.conversion.input.bytes", info.Size()))
|
||||
}
|
||||
|
||||
return attrs
|
||||
}
|
||||
|
||||
// libreofficeErrorType maps a conversion error to LibreOffice's bounded reason
|
||||
// value, reused as the span error.type. Generic failures fall back to
|
||||
// [gotenberg.ClassifyError].
|
||||
func libreofficeErrorType(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidPdfFormats):
|
||||
return gotenberg.ErrorTypeInvalidInput
|
||||
case errors.Is(err, ErrUnoException), errors.Is(err, ErrRuntimeException):
|
||||
return "libreoffice_exception"
|
||||
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded), errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
|
||||
return "libreoffice_unavailable"
|
||||
default:
|
||||
return gotenberg.ClassifyError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Extensions returns the file extensions available for conversions.
|
||||
// FIXME: don't care, take all on the route level?
|
||||
func (a *Api) Extensions() []string {
|
||||
|
||||
44
pkg/modules/libreoffice/api/api_request_attrs_test.go
Normal file
44
pkg/modules/libreoffice/api/api_request_attrs_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestConversionRequestAttributes(t *testing.T) {
|
||||
tmp := filepath.Join(t.TempDir(), "in.docx")
|
||||
content := []byte("hello world")
|
||||
if err := os.WriteFile(tmp, content, 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
options := Options{
|
||||
Landscape: true,
|
||||
PageRanges: "1-3",
|
||||
PdfFormats: gotenberg.PdfFormats{PdfA: "PDF/A-2b", PdfUa: true},
|
||||
}
|
||||
|
||||
got := map[string]any{}
|
||||
for _, kv := range conversionRequestAttributes(tmp, options) {
|
||||
got[string(kv.Key)] = kv.Value.AsInterface()
|
||||
}
|
||||
|
||||
if got["gotenberg.libreoffice.pdf_a"] != "PDF/A-2b" {
|
||||
t.Errorf("pdf_a = %v, want PDF/A-2b", got["gotenberg.libreoffice.pdf_a"])
|
||||
}
|
||||
if got["gotenberg.libreoffice.pdf_ua"] != true {
|
||||
t.Errorf("pdf_ua = %v, want true", got["gotenberg.libreoffice.pdf_ua"])
|
||||
}
|
||||
if got["gotenberg.conversion.landscape"] != true {
|
||||
t.Errorf("landscape = %v, want true", got["gotenberg.conversion.landscape"])
|
||||
}
|
||||
if got["gotenberg.conversion.has_page_ranges"] != true {
|
||||
t.Errorf("has_page_ranges = %v, want true", got["gotenberg.conversion.has_page_ranges"])
|
||||
}
|
||||
if got["gotenberg.conversion.input.bytes"] != int64(len(content)) {
|
||||
t.Errorf("input.bytes = %v, want %d", got["gotenberg.conversion.input.bytes"], len(content))
|
||||
}
|
||||
}
|
||||
84
pkg/modules/libreoffice/api/core_dumped_test.go
Normal file
84
pkg/modules/libreoffice/api/core_dumped_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func sumInt64Counter(rm metricdata.ResourceMetrics, name string) int64 {
|
||||
var total int64
|
||||
for _, sm := range rm.ScopeMetrics {
|
||||
for _, m := range sm.Metrics {
|
||||
if m.Name != name {
|
||||
continue
|
||||
}
|
||||
if sum, ok := m.Data.(metricdata.Sum[int64]); ok {
|
||||
for _, dp := range sum.DataPoints {
|
||||
total += dp.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func TestApi_Pdf_CoreDumpedRetryCap(t *testing.T) {
|
||||
reader := sdkmetric.NewManualReader()
|
||||
provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
|
||||
previous := otel.GetMeterProvider()
|
||||
otel.SetMeterProvider(provider)
|
||||
t.Cleanup(func() { otel.SetMeterProvider(previous) })
|
||||
|
||||
var runCalls int
|
||||
supervisor := &gotenberg.ProcessSupervisorMock{
|
||||
RunMock: func(_ context.Context, _ *slog.Logger, _ func() error) error {
|
||||
runCalls++
|
||||
return ErrCoreDumped
|
||||
},
|
||||
ReqQueueSizeMock: func() int64 { return 0 },
|
||||
ConversionsSinceRestartMock: func() int64 { return 0 },
|
||||
}
|
||||
|
||||
a := &Api{supervisor: supervisor}
|
||||
meter := gotenberg.Meter()
|
||||
a.reqsCounter, _ = meter.Int64Counter("libreoffice.requests.total")
|
||||
a.errsCounter, _ = meter.Int64Counter("libreoffice.errors.total")
|
||||
a.conversionDurationCounter, _ = meter.Float64Histogram("libreoffice.conversion.duration")
|
||||
a.queueWaitDurationCounter, _ = meter.Float64Histogram("libreoffice.queue.wait.duration")
|
||||
a.pdfOutputSizeCounter, _ = meter.Int64Histogram("libreoffice.pdf.output.size")
|
||||
a.coreDumpedRetriesCounter, _ = meter.Int64Counter("libreoffice.conversion.retries.total")
|
||||
|
||||
err := a.Pdf(context.Background(), slog.New(slog.DiscardHandler), "/nonexistent/in.docx", "/tmp/out.pdf", Options{})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error after exhausting the retries")
|
||||
}
|
||||
if !errors.Is(err, ErrCoreDumped) {
|
||||
t.Errorf("expected ErrCoreDumped, got %v", err)
|
||||
}
|
||||
|
||||
// 1 initial attempt + 10 retries.
|
||||
if runCalls != 11 {
|
||||
t.Errorf("supervisor.Run called %d times, want 11", runCalls)
|
||||
}
|
||||
|
||||
var rm metricdata.ResourceMetrics
|
||||
if err := reader.Collect(context.Background(), &rm); err != nil {
|
||||
t.Fatalf("collect: %v", err)
|
||||
}
|
||||
|
||||
if retries := sumInt64Counter(rm, "libreoffice.conversion.retries.total"); retries != 10 {
|
||||
t.Errorf("retries counter = %d, want 10", retries)
|
||||
}
|
||||
// Per-attempt request metric must be preserved: one per attempt.
|
||||
if reqs := sumInt64Counter(rm, "libreoffice.requests.total"); reqs != 11 {
|
||||
t.Errorf("requests counter = %d, want 11", reqs)
|
||||
}
|
||||
}
|
||||
33
pkg/modules/libreoffice/api/errortype_test.go
Normal file
33
pkg/modules/libreoffice/api/errortype_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestLibreofficeErrorType(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{"deadline", context.DeadlineExceeded, "timeout"},
|
||||
{"canceled", context.Canceled, "context_cancelled"},
|
||||
{"invalid pdf formats", ErrInvalidPdfFormats, "invalid_input"},
|
||||
{"uno exception", ErrUnoException, "libreoffice_exception"},
|
||||
{"runtime exception", ErrRuntimeException, "libreoffice_exception"},
|
||||
{"queue size exceeded", gotenberg.ErrMaximumQueueSizeExceeded, "libreoffice_unavailable"},
|
||||
{"process restarting", gotenberg.ErrProcessAlreadyRestarting, "libreoffice_unavailable"},
|
||||
{"core dumped", ErrCoreDumped, "unknown"},
|
||||
{"unknown", errors.New("boom"), "unknown"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := libreofficeErrorType(tc.err); got != tc.want {
|
||||
t.Errorf("libreofficeErrorType(%v) = %q, want %q", tc.err, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -69,11 +70,13 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
|
||||
|
||||
// LibreOffice fetches external content (OOXML images via
|
||||
// TargetMode=External, RTF INCLUDEPICTURE, ODT linked images) inside
|
||||
// its own libcurl. Route those fetches through the in-process proxy
|
||||
// so the chromium/webhook SSRF filters apply.
|
||||
if err := writeSofficeProxyConfig(userProfileDirPath, proxy.Addr()); err != nil {
|
||||
// its own libcurl. The profile config routes those fetches through the
|
||||
// in-process proxy so the chromium/webhook SSRF filters apply, and
|
||||
// blocks content linked from untrusted locations so absolute-path
|
||||
// (file://) and direct fetches are dropped at the source.
|
||||
if err := writeSofficeProfileConfig(userProfileDirPath, proxy.Addr()); err != nil {
|
||||
_ = proxy.Stop(context.Background())
|
||||
return fmt.Errorf("write soffice proxy config: %w", err)
|
||||
return fmt.Errorf("write soffice profile config: %w", err)
|
||||
}
|
||||
sofficeEnv := sofficeProxyEnv(os.Environ(), proxy.Addr())
|
||||
|
||||
@@ -195,7 +198,7 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
|
||||
select {
|
||||
case err = <-connChan:
|
||||
if err != nil {
|
||||
return fmt.Errorf("LibreOffice socket not available: %w", err)
|
||||
return fmt.Errorf("LibreOffice did not become available within the start timeout; increase --libreoffice-start-timeout or check system resources: %w", err)
|
||||
}
|
||||
|
||||
logger.DebugContext(context.Background(), "LibreOffice socket available")
|
||||
@@ -203,7 +206,7 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
|
||||
|
||||
return nil
|
||||
case err = <-waitChan:
|
||||
return fmt.Errorf("LibreOffice process exited: %w", err)
|
||||
return fmt.Errorf("LibreOffice exited unexpectedly during startup; check system resources such as memory, disk, and permissions: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,6 +322,16 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, input
|
||||
args = append(args, "--disable-update-indexes")
|
||||
}
|
||||
|
||||
// A CSV becomes a single Calc sheet named after the input file, and Calc's
|
||||
// default page style prints that sheet name as a centered header. Uploads
|
||||
// are stored under a UUID-based filename, so the UUID would otherwise leak
|
||||
// into the rendered PDF. Suppress the header for CSV inputs; spreadsheets
|
||||
// that carry their own page styles (XLSX, ODS) are left untouched.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1568.
|
||||
if strings.EqualFold(filepath.Ext(inputPath), ".csv") {
|
||||
args = append(args, "--disable-calc-header")
|
||||
}
|
||||
|
||||
args = append(args, "--export", fmt.Sprintf("ExportFormFields=%t", options.ExportFormFields))
|
||||
args = append(args, "--export", fmt.Sprintf("AllowDuplicateFieldNames=%t", options.AllowDuplicateFieldNames))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportBookmarks=%t", options.ExportBookmarks))
|
||||
|
||||
@@ -15,16 +15,18 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"golang.org/x/net/http/httpproxy"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
// outboundProxyOptions configures a [libreOfficeProxy].
|
||||
type outboundProxyOptions struct {
|
||||
allowList []*regexp2.Regexp
|
||||
denyList []*regexp2.Regexp
|
||||
denyPrivateIPs bool
|
||||
denyPublicIPs bool
|
||||
allowList []*regexp2.Regexp
|
||||
denyList []*regexp2.Regexp
|
||||
denyPrivateIPs bool
|
||||
denyPublicIPs bool
|
||||
enableEnvironmentProxy bool
|
||||
}
|
||||
|
||||
// libreOfficeProxy is an HTTP/HTTPS forward proxy that LibreOffice routes
|
||||
@@ -45,6 +47,12 @@ type libreOfficeProxy struct {
|
||||
opts outboundProxyOptions
|
||||
logger *slog.Logger
|
||||
|
||||
// upstreamProxy resolves the upstream (corporate) proxy for a destination
|
||||
// URL from the standard proxy environment variables, or returns a nil URL
|
||||
// to connect directly. Nil unless the operator opted into proxy-
|
||||
// environment honoring. See https://github.com/gotenberg/gotenberg/issues/1592.
|
||||
upstreamProxy func(*url.URL) (*url.URL, error)
|
||||
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
@@ -64,10 +72,15 @@ func newLibreOfficeProxy(logger *slog.Logger, opts outboundProxyOptions) (*libre
|
||||
|
||||
p := &libreOfficeProxy{
|
||||
listener: listener,
|
||||
client: gotenberg.NewOutboundHttpClient(0, opts.allowList, opts.denyList, decideOpts...),
|
||||
client: gotenberg.NewOutboundHttpClient(0, opts.allowList, opts.denyList, opts.enableEnvironmentProxy, decideOpts...),
|
||||
opts: opts,
|
||||
logger: logger.With(slog.String("logger", "libreoffice-proxy")),
|
||||
}
|
||||
if opts.enableEnvironmentProxy {
|
||||
// Honor the standard proxy environment variables, credentials
|
||||
// included. httpproxy reads the environment now and applies NO_PROXY.
|
||||
p.upstreamProxy = httpproxy.FromEnvironment().ProxyFunc()
|
||||
}
|
||||
p.server = &http.Server{
|
||||
Handler: p,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
@@ -182,8 +195,25 @@ func (p *libreOfficeProxy) handleConnect(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// When the operator routes egress through an authenticated proxy, soffice
|
||||
// cannot supply the credentials, so the proxy performs the CONNECT (and
|
||||
// authentication) upstream. The decision above still gated the destination.
|
||||
var proxyURL *url.URL
|
||||
if p.upstreamProxy != nil {
|
||||
proxyURL, err = p.upstreamProxy(&url.URL{Scheme: "https", Host: net.JoinHostPort(host, port)})
|
||||
if err != nil {
|
||||
p.logger.WarnContext(r.Context(), fmt.Sprintf("LibreOffice proxy resolve upstream proxy for '%s': %s", rawURL, err))
|
||||
http.Error(w, "proxy: upstream proxy error", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var dest net.Conn
|
||||
switch {
|
||||
case proxyURL != nil:
|
||||
dest, err = gotenberg.DialThroughProxy(r.Context(), proxyURL, r.Host, func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return net.DialTimeout(network, addr, 10*time.Second)
|
||||
})
|
||||
case len(decision.Pinned) > 0:
|
||||
dest, err = gotenberg.DialPinned(r.Context(), "tcp", decision.Pinned, port)
|
||||
default:
|
||||
@@ -245,13 +275,26 @@ var hopByHopHeaders = []string{
|
||||
"Upgrade",
|
||||
}
|
||||
|
||||
// sofficeProxyConfigTmpl is the registrymodifications.xcu fragment that
|
||||
// tells soffice's UCB layer to route every HTTP and HTTPS fetch through
|
||||
// proxyHost:proxyPort. The %s placeholders accept the proxy host and
|
||||
// port respectively (host first, port second, repeated for HTTP and
|
||||
// HTTPS).
|
||||
const sofficeProxyConfigTmpl = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
// sofficeProfileConfigTmpl is the registrymodifications.xcu the soffice
|
||||
// daemon loads at startup. It does two things:
|
||||
//
|
||||
// 1. Routes every HTTP and HTTPS fetch through proxyHost:proxyPort so
|
||||
// soffice's own libcurl fetches hit the in-process SSRF proxy.
|
||||
// 2. Sets BlockUntrustedRefererLinks so soffice refuses to load content
|
||||
// linked from a document that sits in an untrusted location.
|
||||
//
|
||||
// The second setting closes the local-read and direct-fetch vectors the
|
||||
// proxy cannot see. A document that links an absolute path
|
||||
// (file:///etc/...) or any URL is loaded from the per-request temp dir,
|
||||
// which is never a trusted location, so soffice drops the linked content
|
||||
// instead of resolving it. Embedded content (stored inside the document)
|
||||
// is unaffected.
|
||||
//
|
||||
// The %s placeholders accept the proxy host and port respectively (host
|
||||
// first, port second, repeated for HTTP and HTTPS).
|
||||
const sofficeProfileConfigTmpl = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<oor:items xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<item oor:path="/org.openoffice.Office.Common/Security/Scripting"><prop oor:name="BlockUntrustedRefererLinks" oor:op="fuse"><value>true</value></prop></item>
|
||||
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetProxyType" oor:op="fuse"><value>1</value></prop></item>
|
||||
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetHTTPProxyName" oor:op="fuse"><value>%s</value></prop></item>
|
||||
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetHTTPProxyPort" oor:op="fuse"><value>%s</value></prop></item>
|
||||
@@ -261,10 +304,11 @@ const sofficeProxyConfigTmpl = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
</oor:items>
|
||||
`
|
||||
|
||||
// writeSofficeProxyConfig drops a registrymodifications.xcu file into
|
||||
// writeSofficeProfileConfig drops a registrymodifications.xcu file into
|
||||
// userProfileDirPath/user/ that points soffice's UCB layer at proxyAddr
|
||||
// for both HTTP and HTTPS. proxyAddr must be a host:port pair.
|
||||
func writeSofficeProxyConfig(userProfileDirPath, proxyAddr string) error {
|
||||
// for both HTTP and HTTPS and blocks linked content from untrusted
|
||||
// locations. proxyAddr must be a host:port pair.
|
||||
func writeSofficeProfileConfig(userProfileDirPath, proxyAddr string) error {
|
||||
host, port, err := net.SplitHostPort(proxyAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("split proxy address %q: %w", proxyAddr, err)
|
||||
@@ -276,7 +320,7 @@ func writeSofficeProxyConfig(userProfileDirPath, proxyAddr string) error {
|
||||
return fmt.Errorf("create soffice user profile directory: %w", err)
|
||||
}
|
||||
|
||||
body := fmt.Sprintf(sofficeProxyConfigTmpl, host, port, host, port)
|
||||
body := fmt.Sprintf(sofficeProfileConfigTmpl, host, port, host, port)
|
||||
err = os.WriteFile(userDir+"/registrymodifications.xcu", []byte(body), 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write registrymodifications.xcu: %w", err)
|
||||
|
||||
@@ -280,11 +280,11 @@ func TestLibreOfficeProxy_StopIsIdempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSofficeProxyConfig(t *testing.T) {
|
||||
func TestWriteSofficeProfileConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
if err := writeSofficeProxyConfig(dir, "127.0.0.1:9876"); err != nil {
|
||||
t.Fatalf("writeSofficeProxyConfig: %v", err)
|
||||
if err := writeSofficeProfileConfig(dir, "127.0.0.1:9876"); err != nil {
|
||||
t.Fatalf("writeSofficeProfileConfig: %v", err)
|
||||
}
|
||||
|
||||
body, err := os.ReadFile(filepath.Join(dir, "user", "registrymodifications.xcu"))
|
||||
@@ -297,6 +297,9 @@ func TestWriteSofficeProxyConfig(t *testing.T) {
|
||||
`ooInetHTTPProxyName`, `<value>127.0.0.1</value>`,
|
||||
`ooInetHTTPProxyPort`, `<value>9876</value>`,
|
||||
`ooInetHTTPSProxyName`, `ooInetHTTPSProxyPort`,
|
||||
// Blocks linked content from untrusted locations, closing the
|
||||
// file:// local-read and direct-fetch vectors the proxy cannot see.
|
||||
`BlockUntrustedRefererLinks`, `<value>true</value>`,
|
||||
} {
|
||||
if !strings.Contains(string(body), want) {
|
||||
t.Errorf("xcu missing %q\nfull body:\n%s", want, body)
|
||||
@@ -304,8 +307,8 @@ func TestWriteSofficeProxyConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSofficeProxyConfig_InvalidAddr(t *testing.T) {
|
||||
err := writeSofficeProxyConfig(t.TempDir(), "not-a-host-port")
|
||||
func TestWriteSofficeProfileConfig_InvalidAddr(t *testing.T) {
|
||||
err := writeSofficeProfileConfig(t.TempDir(), "not-a-host-port")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed proxy address")
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ func (engine *LibreOfficePdfEngine) ReadBookmarks(ctx context.Context, logger *s
|
||||
}
|
||||
|
||||
// Encrypt is not available in this implementation.
|
||||
func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
|
||||
func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
|
||||
return fmt.Errorf("encrypt PDF using LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
@@ -135,6 +135,16 @@ func (engine *LibreOfficePdfEngine) Rotate(ctx context.Context, logger *slog.Log
|
||||
return fmt.Errorf("rotate PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// InjectFacturXXMP is not available in this implementation.
|
||||
func (engine *LibreOfficePdfEngine) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error {
|
||||
return fmt.Errorf("inject Factur-X XMP with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// ReadPdfAConformance is not available in this implementation.
|
||||
func (engine *LibreOfficePdfEngine) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
|
||||
return "", "", fmt.Errorf("read PDF/A conformance with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*LibreOfficePdfEngine)(nil)
|
||||
|
||||
@@ -30,7 +30,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
||||
splitMode := pdfengines.FormDataPdfSplitMode(form, false)
|
||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
||||
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
||||
@@ -38,6 +38,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||
angle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||
|
||||
zeroValuedSplitMode := gotenberg.SplitMode{}
|
||||
|
||||
@@ -313,13 +314,27 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
||||
return fmt.Errorf("validate stamp: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
|
||||
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = pdfengines.ValidatePdfEncryptCompat(encrypt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = pdfengines.ValidateFacturXCompat(facturX, facturxXmlPath, pdfFormats)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Factur-X requires PDF/A-3; default to PDF/A-3b when no format was
|
||||
// requested. The conversion runs as a post-processing step below.
|
||||
pdfFormats = pdfengines.FacturXPdfFormats(ctx, engine, facturX, pdfFormats, true, nil)
|
||||
|
||||
hasPostProcessing := watermark.Source != "" || stamp.Source != "" || angle != 0 ||
|
||||
len(embedPaths) > 0 || len(metadata) > 0 || flatten
|
||||
len(embedPaths) > 0 || len(metadata) > 0 || flatten || facturX.ConformanceLevel != ""
|
||||
|
||||
outputPaths := make([]string, len(inputPaths))
|
||||
for i, inputPath := range inputPaths {
|
||||
@@ -385,7 +400,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
||||
fmt.Errorf("convert to PDF: %w", err),
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusBadRequest,
|
||||
fmt.Sprintf("A PDF format in '%+v' is not supported", pdfFormats),
|
||||
fmt.Sprintf("The PDF format '%s' is not supported. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.", pdfFormats.PdfA),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -503,7 +518,12 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
||||
return fmt.Errorf("set embeds metadata: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
|
||||
err = pdfengines.ApplyFacturXStub(ctx, engine, facturX, facturxXmlPath, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply Factur-X: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.EncryptPdfStub(ctx, engine, encrypt, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||
}
|
||||
|
||||
@@ -13,8 +13,10 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
@@ -30,6 +32,9 @@ func init() {
|
||||
// [gotenberg.PdfEngine] interface.
|
||||
type PdfCpu struct {
|
||||
binPath string
|
||||
|
||||
version string
|
||||
versionOnce sync.Once
|
||||
}
|
||||
|
||||
type pdfcpuBookmark struct {
|
||||
@@ -74,35 +79,60 @@ func (engine *PdfCpu) Validate() error {
|
||||
|
||||
// Debug returns additional debug data.
|
||||
func (engine *PdfCpu) Debug() map[string]any {
|
||||
debug := make(map[string]any)
|
||||
return map[string]any{"version": engine.detectVersion()}
|
||||
}
|
||||
|
||||
cmd := exec.Command(engine.binPath, "version") //nolint:gosec
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
debug["version"] = err.Error()
|
||||
return debug
|
||||
}
|
||||
|
||||
debug["version"] = "Unable to determine pdfcpu version"
|
||||
|
||||
lines := strings.SplitSeq(string(output), "\n")
|
||||
for line := range lines {
|
||||
if after, ok := strings.CutPrefix(line, "pdfcpu:"); ok {
|
||||
debug["version"] = strings.TrimSpace(after)
|
||||
break
|
||||
// detectVersion resolves the pdfcpu version once, preferring the value captured
|
||||
// at image build time so it never spawns pdfcpu at runtime. It falls back to
|
||||
// running pdfcpu version for local or non-Docker builds.
|
||||
func (engine *PdfCpu) detectVersion() string {
|
||||
engine.versionOnce.Do(func() {
|
||||
if v, ok := gotenberg.BuildVersion("pdfcpu"); ok {
|
||||
engine.version = v
|
||||
return
|
||||
}
|
||||
|
||||
cmd := exec.Command(engine.binPath, "version") //nolint:gosec
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
engine.version = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
engine.version = "Unable to determine pdfcpu version"
|
||||
|
||||
lines := strings.SplitSeq(string(output), "\n")
|
||||
for line := range lines {
|
||||
if after, ok := strings.CutPrefix(line, "pdfcpu:"); ok {
|
||||
engine.version = strings.TrimSpace(after)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return engine.version
|
||||
}
|
||||
|
||||
// spanAttrs returns the client-span attributes for a pdfcpu invocation: the
|
||||
// server address and the pdfcpu version, plus any extra attributes. The version
|
||||
// rides on every span so a trace records which pdfcpu ran the operation.
|
||||
func (engine *PdfCpu) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue {
|
||||
attrs := make([]attribute.KeyValue, 0, 2+len(extra))
|
||||
attrs = append(attrs, semconv.ServerAddress(engine.binPath))
|
||||
if v := engine.detectVersion(); v != "" {
|
||||
attrs = append(attrs, attribute.String("gotenberg.pdfcpu.version", v))
|
||||
}
|
||||
|
||||
return debug
|
||||
return append(attrs, extra...)
|
||||
}
|
||||
|
||||
// Merge combines multiple PDFs into a single PDF.
|
||||
func (engine *PdfCpu) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Merge",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -134,7 +164,7 @@ func (engine *PdfCpu) Merge(ctx context.Context, logger *slog.Logger, inputPaths
|
||||
func (engine *PdfCpu) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Split",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -203,7 +233,7 @@ func (engine *PdfCpu) Split(ctx context.Context, logger *slog.Logger, mode goten
|
||||
func (engine *PdfCpu) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Flatten",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -217,7 +247,7 @@ func (engine *PdfCpu) Flatten(ctx context.Context, logger *slog.Logger, inputPat
|
||||
func (engine *PdfCpu) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Convert",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -231,7 +261,7 @@ func (engine *PdfCpu) Convert(ctx context.Context, logger *slog.Logger, formats
|
||||
func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.ReadMetadata",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -245,7 +275,7 @@ func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *slog.Logger, inp
|
||||
func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.WriteMetadata",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -259,7 +289,7 @@ func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *slog.Logger, me
|
||||
func (engine *PdfCpu) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.PageCount",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -273,7 +303,7 @@ func (engine *PdfCpu) PageCount(ctx context.Context, logger *slog.Logger, inputP
|
||||
func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.ReadBookmarks",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -376,7 +406,7 @@ func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *slog.Logger, in
|
||||
func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.WriteBookmarks",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -452,12 +482,22 @@ func (engine *PdfCpu) EmbedFilesMetadata(ctx context.Context, logger *slog.Logge
|
||||
return fmt.Errorf("set embeds metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// InjectFacturXXMP is not available in this implementation.
|
||||
func (engine *PdfCpu) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error {
|
||||
return fmt.Errorf("inject Factur-X XMP with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// ReadPdfAConformance is not available in this implementation.
|
||||
func (engine *PdfCpu) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
|
||||
return "", "", fmt.Errorf("read PDF/A conformance with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
|
||||
// without modifying the main PDF content.
|
||||
func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.EmbedFiles",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -493,30 +533,38 @@ func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *slog.Logger, fileP
|
||||
}
|
||||
|
||||
// Encrypt adds password protection to a PDF file using pdfcpu.
|
||||
func (engine *PdfCpu) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
|
||||
func (engine *PdfCpu) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Encrypt",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
if userPassword == "" {
|
||||
err := errors.New("user password cannot be empty")
|
||||
ownerPassword := opts.OwnerPassword
|
||||
if ownerPassword == "" {
|
||||
ownerPassword = opts.UserPassword
|
||||
}
|
||||
|
||||
// An empty user password is allowed: it produces an owner-only document.
|
||||
if opts.UserPassword == "" && ownerPassword == "" {
|
||||
err := errors.New("at least a user or owner password is required")
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
if ownerPassword == "" {
|
||||
ownerPassword = userPassword
|
||||
// pdfcpu only supports coarse permissions: all actions or none.
|
||||
perm := "all"
|
||||
if opts.Permissions.Restricted() {
|
||||
perm = "none"
|
||||
}
|
||||
|
||||
args := make([]string, 0, 11)
|
||||
args = append(args, "encrypt")
|
||||
args = append(args, "--mode", "aes")
|
||||
args = append(args, "--upw", userPassword)
|
||||
args = append(args, "--upw", opts.UserPassword)
|
||||
args = append(args, "--opw", ownerPassword)
|
||||
args = append(args, "--perm", "all")
|
||||
args = append(args, "--perm", perm)
|
||||
args = append(args, inputPath, inputPath)
|
||||
|
||||
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
||||
@@ -543,7 +591,7 @@ func (engine *PdfCpu) Encrypt(ctx context.Context, logger *slog.Logger, inputPat
|
||||
func (engine *PdfCpu) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Watermark",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -562,7 +610,7 @@ func (engine *PdfCpu) Watermark(ctx context.Context, logger *slog.Logger, inputP
|
||||
func (engine *PdfCpu) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Stamp",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -581,7 +629,7 @@ func (engine *PdfCpu) Stamp(ctx context.Context, logger *slog.Logger, inputPath
|
||||
func (engine *PdfCpu) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Rotate",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
|
||||
169
pkg/modules/pdfengines/fallback_test.go
Normal file
169
pkg/modules/pdfengines/fallback_test.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package pdfengines
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func newFallbackRecorder(t *testing.T) *tracetest.SpanRecorder {
|
||||
t.Helper()
|
||||
recorder := tracetest.NewSpanRecorder()
|
||||
provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
|
||||
previous := otel.GetTracerProvider()
|
||||
otel.SetTracerProvider(provider)
|
||||
t.Cleanup(func() { otel.SetTracerProvider(previous) })
|
||||
return recorder
|
||||
}
|
||||
|
||||
func findFallbackSpan(recorder *tracetest.SpanRecorder, name string) sdktrace.ReadOnlySpan {
|
||||
for _, s := range recorder.Ended() {
|
||||
if s.Name() == name {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func wrapTest(err error) error { return fmt.Errorf("test op with multi PDF engines: %w", err) }
|
||||
|
||||
func TestRunWithFallback_FirstSucceeds(t *testing.T) {
|
||||
recorder := newFallbackRecorder(t)
|
||||
engines := []gotenberg.PdfEngine{&gotenberg.PdfEngineMock{}, &gotenberg.PdfEngineMock{}}
|
||||
|
||||
calls := 0
|
||||
got, err := runWithFallback(context.Background(), "pdfengines.Test", engines,
|
||||
func(_ context.Context, _ gotenberg.PdfEngine) (string, error) {
|
||||
calls++
|
||||
return "ok", nil
|
||||
}, wrapTest)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "ok" {
|
||||
t.Errorf("got %q, want ok", got)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("expected 1 engine call, got %d", calls)
|
||||
}
|
||||
|
||||
span := findFallbackSpan(recorder, "pdfengines.Test")
|
||||
if span.Status().Code != codes.Ok {
|
||||
t.Errorf("status = %v, want Ok", span.Status().Code)
|
||||
}
|
||||
attrs := map[string]string{}
|
||||
for _, kv := range span.Attributes() {
|
||||
attrs[string(kv.Key)] = kv.Value.Emit()
|
||||
}
|
||||
if attrs["gotenberg.pdf_engine.attempts"] != "1" {
|
||||
t.Errorf("attempts = %q, want 1", attrs["gotenberg.pdf_engine.attempts"])
|
||||
}
|
||||
if attrs["gotenberg.pdf_engine.selected"] == "" {
|
||||
t.Error("expected a selected engine attribute")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWithFallback_SecondSucceeds(t *testing.T) {
|
||||
recorder := newFallbackRecorder(t)
|
||||
engines := []gotenberg.PdfEngine{&gotenberg.PdfEngineMock{}, &gotenberg.PdfEngineMock{}}
|
||||
|
||||
calls := 0
|
||||
got, err := runWithFallback(context.Background(), "pdfengines.Test", engines,
|
||||
func(_ context.Context, _ gotenberg.PdfEngine) (string, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return "", errors.New("first engine failed")
|
||||
}
|
||||
return "ok", nil
|
||||
}, wrapTest)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "ok" || calls != 2 {
|
||||
t.Errorf("got %q after %d calls, want ok after 2", got, calls)
|
||||
}
|
||||
|
||||
span := findFallbackSpan(recorder, "pdfengines.Test")
|
||||
var failedEvents int
|
||||
for _, e := range span.Events() {
|
||||
if e.Name == "pdf_engine.attempt_failed" {
|
||||
failedEvents++
|
||||
}
|
||||
}
|
||||
if failedEvents != 1 {
|
||||
t.Errorf("expected 1 attempt_failed event, got %d", failedEvents)
|
||||
}
|
||||
for _, kv := range span.Attributes() {
|
||||
if string(kv.Key) == "gotenberg.pdf_engine.attempts" && kv.Value.Emit() != "2" {
|
||||
t.Errorf("attempts = %q, want 2", kv.Value.Emit())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWithFallback_AllFail(t *testing.T) {
|
||||
recorder := newFallbackRecorder(t)
|
||||
engines := []gotenberg.PdfEngine{&gotenberg.PdfEngineMock{}, &gotenberg.PdfEngineMock{}}
|
||||
|
||||
sentinel := errors.New("engine failed")
|
||||
_, err := runWithFallback(context.Background(), "pdfengines.Test", engines,
|
||||
func(_ context.Context, _ gotenberg.PdfEngine) (string, error) {
|
||||
return "", sentinel
|
||||
}, wrapTest)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when all engines fail")
|
||||
}
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Errorf("expected the joined engine error to be wrapped, got %v", err)
|
||||
}
|
||||
|
||||
span := findFallbackSpan(recorder, "pdfengines.Test")
|
||||
if span.Status().Code != codes.Error {
|
||||
t.Errorf("status = %v, want Error", span.Status().Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWithFallback_ZeroEngines(t *testing.T) {
|
||||
newFallbackRecorder(t)
|
||||
_, err := runWithFallback(context.Background(), "pdfengines.Test", nil,
|
||||
func(_ context.Context, _ gotenberg.PdfEngine) (string, error) {
|
||||
return "ok", nil
|
||||
}, wrapTest)
|
||||
if err == nil {
|
||||
t.Error("expected an error with no engines")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWithFallback_ContextDone(t *testing.T) {
|
||||
recorder := newFallbackRecorder(t)
|
||||
engines := []gotenberg.PdfEngine{&gotenberg.PdfEngineMock{}}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
release := make(chan struct{})
|
||||
t.Cleanup(func() { close(release) })
|
||||
|
||||
_, err := runWithFallback(ctx, "pdfengines.Test", engines,
|
||||
func(_ context.Context, _ gotenberg.PdfEngine) (string, error) {
|
||||
<-release // never returns during the call, forcing the ctx.Done branch
|
||||
return "", nil
|
||||
}, wrapTest)
|
||||
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("expected context.Canceled, got %v", err)
|
||||
}
|
||||
|
||||
span := findFallbackSpan(recorder, "pdfengines.Test")
|
||||
if span.Status().Code != codes.Error {
|
||||
t.Errorf("status = %v, want Error (the cancellation must mark the span)", span.Status().Code)
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
@@ -28,6 +28,7 @@ type multiPdfEngines struct {
|
||||
watermarkEngines []gotenberg.PdfEngine
|
||||
stampEngines []gotenberg.PdfEngine
|
||||
rotateEngines []gotenberg.PdfEngine
|
||||
facturXEngines []gotenberg.PdfEngine
|
||||
}
|
||||
|
||||
func newMultiPdfEngines(
|
||||
@@ -44,7 +45,8 @@ func newMultiPdfEngines(
|
||||
writeBookmarksEngines,
|
||||
watermarkEngines,
|
||||
stampEngines,
|
||||
rotateEngines []gotenberg.PdfEngine,
|
||||
rotateEngines,
|
||||
facturXEngines []gotenberg.PdfEngine,
|
||||
) *multiPdfEngines {
|
||||
return &multiPdfEngines{
|
||||
mergeEngines: mergeEngines,
|
||||
@@ -61,586 +63,292 @@ func newMultiPdfEngines(
|
||||
watermarkEngines: watermarkEngines,
|
||||
stampEngines: stampEngines,
|
||||
rotateEngines: rotateEngines,
|
||||
facturXEngines: facturXEngines,
|
||||
}
|
||||
}
|
||||
|
||||
// engineName returns the module ID of a PDF engine for telemetry, falling back
|
||||
// to its type name when it does not expose a descriptor.
|
||||
func engineName(engine gotenberg.PdfEngine) string {
|
||||
if module, ok := engine.(gotenberg.Module); ok {
|
||||
return module.Descriptor().ID
|
||||
}
|
||||
return fmt.Sprintf("%T", engine)
|
||||
}
|
||||
|
||||
// runWithFallback runs op against each engine in order and returns the first
|
||||
// success. It wraps the attempts in a pdfengines span, records the winning
|
||||
// engine and attempt count, emits a pdf_engine.attempt_failed event for each
|
||||
// failed engine, and joins all engine errors on total failure. A context
|
||||
// cancellation marks the span as errored too. wrap applies the op-specific
|
||||
// final error message.
|
||||
func runWithFallback[T any](
|
||||
ctx context.Context,
|
||||
spanName string,
|
||||
engines []gotenberg.PdfEngine,
|
||||
op func(ctx context.Context, engine gotenberg.PdfEngine) (T, error),
|
||||
wrap func(err error) error,
|
||||
) (T, error) {
|
||||
var zero T
|
||||
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, spanName, trace.WithSpanKind(trace.SpanKindInternal))
|
||||
defer span.End()
|
||||
|
||||
type attemptResult struct {
|
||||
value T
|
||||
err error
|
||||
}
|
||||
|
||||
var joined error
|
||||
for attempt, engine := range engines {
|
||||
resultChan := make(chan attemptResult, 1)
|
||||
|
||||
go func(engine gotenberg.PdfEngine) {
|
||||
value, err := op(ctx, engine)
|
||||
resultChan <- attemptResult{value: value, err: err}
|
||||
}(engine)
|
||||
|
||||
select {
|
||||
case result := <-resultChan:
|
||||
if result.err == nil {
|
||||
span.SetAttributes(
|
||||
attribute.String("gotenberg.pdf_engine.selected", engineName(engine)),
|
||||
attribute.Int("gotenberg.pdf_engine.attempts", attempt+1),
|
||||
)
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return result.value, nil
|
||||
}
|
||||
|
||||
joined = errors.Join(joined, result.err)
|
||||
span.AddEvent("pdf_engine.attempt_failed", trace.WithAttributes(
|
||||
attribute.String("engine", engineName(engine)),
|
||||
))
|
||||
case <-ctx.Done():
|
||||
err := ctx.Err()
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return zero, err
|
||||
}
|
||||
}
|
||||
|
||||
err := wrap(joined)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
|
||||
return zero, err
|
||||
}
|
||||
|
||||
// runWithFallbackVoid adapts [runWithFallback] for operations that return no
|
||||
// value beyond an error.
|
||||
func runWithFallbackVoid(
|
||||
ctx context.Context,
|
||||
spanName string,
|
||||
engines []gotenberg.PdfEngine,
|
||||
op func(ctx context.Context, engine gotenberg.PdfEngine) error,
|
||||
wrap func(err error) error,
|
||||
) error {
|
||||
_, err := runWithFallback(ctx, spanName, engines,
|
||||
func(ctx context.Context, engine gotenberg.PdfEngine) (struct{}, error) {
|
||||
return struct{}{}, op(ctx, engine)
|
||||
},
|
||||
wrap,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// Merge combines multiple PDF files into a single document using the first
|
||||
// available engine that supports PDF merging.
|
||||
//
|
||||
//nolint:dupl
|
||||
func (multi *multiPdfEngines) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
||||
tracer := gotenberg.Tracer()
|
||||
ctx, span := tracer.Start(ctx, "pdfengines.Merge", trace.WithSpanKind(trace.SpanKindInternal))
|
||||
defer span.End()
|
||||
|
||||
var err error
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
for _, engine := range multi.mergeEngines {
|
||||
go func(engine gotenberg.PdfEngine) {
|
||||
errChan <- engine.Merge(ctx, logger, inputPaths, outputPath)
|
||||
}(engine)
|
||||
|
||||
select {
|
||||
case mergeErr := <-errChan:
|
||||
if mergeErr != nil {
|
||||
err = errors.Join(err, mergeErr)
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
err = fmt.Errorf("merge PDFs with multi PDF engines: %w", err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type splitResult struct {
|
||||
outputPaths []string
|
||||
err error
|
||||
return runWithFallbackVoid(ctx, "pdfengines.Merge", multi.mergeEngines,
|
||||
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||
return engine.Merge(ctx, logger, inputPaths, outputPath)
|
||||
},
|
||||
func(err error) error { return fmt.Errorf("merge PDFs with multi PDF engines: %w", err) },
|
||||
)
|
||||
}
|
||||
|
||||
// Split divides the PDF into separate pages using the first available engine
|
||||
// that supports PDF splitting.
|
||||
func (multi *multiPdfEngines) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
||||
tracer := gotenberg.Tracer()
|
||||
ctx, span := tracer.Start(ctx, "pdfengines.Split", trace.WithSpanKind(trace.SpanKindInternal))
|
||||
defer span.End()
|
||||
|
||||
var err error
|
||||
var mu sync.Mutex // to safely append errors.
|
||||
|
||||
for _, engine := range multi.splitEngines {
|
||||
resultChan := make(chan splitResult, 1)
|
||||
|
||||
go func(engine gotenberg.PdfEngine) {
|
||||
outputPaths, err := engine.Split(ctx, logger, mode, inputPath, outputDirPath)
|
||||
resultChan <- splitResult{outputPaths: outputPaths, err: err}
|
||||
}(engine)
|
||||
|
||||
select {
|
||||
case result := <-resultChan:
|
||||
if result.err != nil {
|
||||
mu.Lock()
|
||||
err = errors.Join(err, result.err)
|
||||
mu.Unlock()
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return result.outputPaths, nil
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
err = fmt.Errorf("split PDF with multi PDF engines: %w", err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
|
||||
return nil, err
|
||||
return runWithFallback(ctx, "pdfengines.Split", multi.splitEngines,
|
||||
func(ctx context.Context, engine gotenberg.PdfEngine) ([]string, error) {
|
||||
return engine.Split(ctx, logger, mode, inputPath, outputDirPath)
|
||||
},
|
||||
func(err error) error { return fmt.Errorf("split PDF with multi PDF engines: %w", err) },
|
||||
)
|
||||
}
|
||||
|
||||
// Flatten merges existing annotation appearances with page content using the
|
||||
// first available engine that supports flattening.
|
||||
func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
|
||||
tracer := gotenberg.Tracer()
|
||||
ctx, span := tracer.Start(ctx, "pdfengines.Flatten", trace.WithSpanKind(trace.SpanKindInternal))
|
||||
defer span.End()
|
||||
|
||||
var err error
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
for _, engine := range multi.flattenEngines {
|
||||
go func(engine gotenberg.PdfEngine) {
|
||||
errChan <- engine.Flatten(ctx, logger, inputPath)
|
||||
}(engine)
|
||||
|
||||
select {
|
||||
case mergeErr := <-errChan:
|
||||
if mergeErr != nil {
|
||||
err = errors.Join(err, mergeErr)
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
err = fmt.Errorf("flatten PDF with multi PDF engines: %w", err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
|
||||
return err
|
||||
return runWithFallbackVoid(ctx, "pdfengines.Flatten", multi.flattenEngines,
|
||||
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||
return engine.Flatten(ctx, logger, inputPath)
|
||||
},
|
||||
func(err error) error { return fmt.Errorf("flatten PDF with multi PDF engines: %w", err) },
|
||||
)
|
||||
}
|
||||
|
||||
// Convert transforms the given PDF to a specific PDF format using the first
|
||||
// available engine that supports PDF conversion.
|
||||
func (multi *multiPdfEngines) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
tracer := gotenberg.Tracer()
|
||||
ctx, span := tracer.Start(ctx, "pdfengines.Convert", trace.WithSpanKind(trace.SpanKindInternal))
|
||||
defer span.End()
|
||||
|
||||
var err error
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
for _, engine := range multi.convertEngines {
|
||||
go func(engine gotenberg.PdfEngine) {
|
||||
errChan <- engine.Convert(ctx, logger, formats, inputPath, outputPath)
|
||||
}(engine)
|
||||
|
||||
select {
|
||||
case mergeErr := <-errChan:
|
||||
if mergeErr != nil {
|
||||
err = errors.Join(err, mergeErr)
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
err = fmt.Errorf("convert PDF to '%+v' with multi PDF engines: %w", formats, err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type readMetadataResult struct {
|
||||
metadata map[string]any
|
||||
err error
|
||||
return runWithFallbackVoid(ctx, "pdfengines.Convert", multi.convertEngines,
|
||||
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||
return engine.Convert(ctx, logger, formats, inputPath, outputPath)
|
||||
},
|
||||
func(err error) error {
|
||||
return fmt.Errorf("convert PDF to '%+v' with multi PDF engines: %w", formats, err)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ReadMetadata extracts metadata from a PDF file using the first available
|
||||
// engine that supports metadata reading.
|
||||
//
|
||||
//nolint:dupl
|
||||
func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||
tracer := gotenberg.Tracer()
|
||||
ctx, span := tracer.Start(ctx, "pdfengines.ReadMetadata", trace.WithSpanKind(trace.SpanKindInternal))
|
||||
defer span.End()
|
||||
|
||||
var err error
|
||||
var mu sync.Mutex // to safely append errors.
|
||||
|
||||
for _, engine := range multi.readMetadataEngines {
|
||||
resultChan := make(chan readMetadataResult, 1)
|
||||
|
||||
go func(engine gotenberg.PdfEngine) {
|
||||
metadata, err := engine.ReadMetadata(ctx, logger, inputPath)
|
||||
resultChan <- readMetadataResult{metadata: metadata, err: err}
|
||||
}(engine)
|
||||
|
||||
select {
|
||||
case result := <-resultChan:
|
||||
if result.err != nil {
|
||||
mu.Lock()
|
||||
err = errors.Join(err, result.err)
|
||||
mu.Unlock()
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return result.metadata, nil
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
err = fmt.Errorf("read PDF metadata with multi PDF engines: %w", err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
|
||||
return nil, err
|
||||
return runWithFallback(ctx, "pdfengines.ReadMetadata", multi.readMetadataEngines,
|
||||
func(ctx context.Context, engine gotenberg.PdfEngine) (map[string]any, error) {
|
||||
return engine.ReadMetadata(ctx, logger, inputPath)
|
||||
},
|
||||
func(err error) error { return fmt.Errorf("read PDF metadata with multi PDF engines: %w", err) },
|
||||
)
|
||||
}
|
||||
|
||||
// WriteMetadata embeds metadata into a PDF file using the first available
|
||||
// engine that supports metadata writing.
|
||||
func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
|
||||
tracer := gotenberg.Tracer()
|
||||
ctx, span := tracer.Start(ctx, "pdfengines.WriteMetadata", trace.WithSpanKind(trace.SpanKindInternal))
|
||||
defer span.End()
|
||||
|
||||
var err error
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
for _, engine := range multi.writeMetadataEngines {
|
||||
go func(engine gotenberg.PdfEngine) {
|
||||
errChan <- engine.WriteMetadata(ctx, logger, metadata, inputPath)
|
||||
}(engine)
|
||||
|
||||
select {
|
||||
case writeMetadataErr := <-errChan:
|
||||
if writeMetadataErr != nil {
|
||||
err = errors.Join(err, writeMetadataErr)
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
err = fmt.Errorf("write PDF metadata with multi PDF engines: %w", err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type pageCountResult struct {
|
||||
pageCount int
|
||||
err error
|
||||
return runWithFallbackVoid(ctx, "pdfengines.WriteMetadata", multi.writeMetadataEngines,
|
||||
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||
return engine.WriteMetadata(ctx, logger, metadata, inputPath)
|
||||
},
|
||||
func(err error) error { return fmt.Errorf("write PDF metadata with multi PDF engines: %w", err) },
|
||||
)
|
||||
}
|
||||
|
||||
// PageCount returns the number of pages in a PDF file using the first available
|
||||
// engine that supports metadata reading.
|
||||
func (multi *multiPdfEngines) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
|
||||
tracer := gotenberg.Tracer()
|
||||
ctx, span := tracer.Start(ctx, "pdfengines.PageCount", trace.WithSpanKind(trace.SpanKindInternal))
|
||||
defer span.End()
|
||||
|
||||
var err error
|
||||
var mu sync.Mutex // to safely append errors.
|
||||
|
||||
for _, engine := range multi.readMetadataEngines {
|
||||
resultChan := make(chan pageCountResult, 1)
|
||||
|
||||
go func(engine gotenberg.PdfEngine) {
|
||||
pageCount, err := engine.PageCount(ctx, logger, inputPath)
|
||||
resultChan <- pageCountResult{pageCount: pageCount, err: err}
|
||||
}(engine)
|
||||
|
||||
select {
|
||||
case result := <-resultChan:
|
||||
if result.err != nil {
|
||||
mu.Lock()
|
||||
err = errors.Join(err, result.err)
|
||||
mu.Unlock()
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return result.pageCount, nil
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
err = fmt.Errorf("page count with multi PDF engines: %w", err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
|
||||
return 0, err
|
||||
}
|
||||
|
||||
type readBookmarksResult struct {
|
||||
bookmarks []gotenberg.Bookmark
|
||||
err error
|
||||
return runWithFallback(ctx, "pdfengines.PageCount", multi.readMetadataEngines,
|
||||
func(ctx context.Context, engine gotenberg.PdfEngine) (int, error) {
|
||||
return engine.PageCount(ctx, logger, inputPath)
|
||||
},
|
||||
func(err error) error { return fmt.Errorf("page count with multi PDF engines: %w", err) },
|
||||
)
|
||||
}
|
||||
|
||||
// ReadBookmarks reads bookmarks from a PDF file using the first available
|
||||
// engine that supports bookmarks reading.
|
||||
//
|
||||
//nolint:dupl
|
||||
func (multi *multiPdfEngines) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
|
||||
tracer := gotenberg.Tracer()
|
||||
ctx, span := tracer.Start(ctx, "pdfengines.ReadBookmarks", trace.WithSpanKind(trace.SpanKindInternal))
|
||||
defer span.End()
|
||||
|
||||
var err error
|
||||
var mu sync.Mutex // to safely append errors.
|
||||
|
||||
for _, engine := range multi.readBookmarksEngines {
|
||||
resultChan := make(chan readBookmarksResult, 1)
|
||||
|
||||
go func(engine gotenberg.PdfEngine) {
|
||||
bookmarks, err := engine.ReadBookmarks(ctx, logger, inputPath)
|
||||
resultChan <- readBookmarksResult{bookmarks: bookmarks, err: err}
|
||||
}(engine)
|
||||
|
||||
select {
|
||||
case result := <-resultChan:
|
||||
if result.err != nil {
|
||||
mu.Lock()
|
||||
err = errors.Join(err, result.err)
|
||||
mu.Unlock()
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return result.bookmarks, nil
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
err = fmt.Errorf("read PDF bookmarks with multi PDF engines: %w", err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
|
||||
return nil, err
|
||||
return runWithFallback(ctx, "pdfengines.ReadBookmarks", multi.readBookmarksEngines,
|
||||
func(ctx context.Context, engine gotenberg.PdfEngine) ([]gotenberg.Bookmark, error) {
|
||||
return engine.ReadBookmarks(ctx, logger, inputPath)
|
||||
},
|
||||
func(err error) error { return fmt.Errorf("read PDF bookmarks with multi PDF engines: %w", err) },
|
||||
)
|
||||
}
|
||||
|
||||
// WriteBookmarks adds a document outline (bookmarks) to a PDF file using the
|
||||
// first available engine that supports bookmarks writing.
|
||||
func (multi *multiPdfEngines) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
|
||||
tracer := gotenberg.Tracer()
|
||||
ctx, span := tracer.Start(ctx, "pdfengines.WriteBookmarks", trace.WithSpanKind(trace.SpanKindInternal))
|
||||
defer span.End()
|
||||
|
||||
var err error
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
for _, engine := range multi.writeBookmarksEngines {
|
||||
go func(engine gotenberg.PdfEngine) {
|
||||
errChan <- engine.WriteBookmarks(ctx, logger, inputPath, bookmarks)
|
||||
}(engine)
|
||||
|
||||
select {
|
||||
case writeBookmarksErr := <-errChan:
|
||||
if writeBookmarksErr != nil {
|
||||
err = errors.Join(err, writeBookmarksErr)
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
err = fmt.Errorf("write PDF bookmarks with multi PDF engines: %w", err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
|
||||
return err
|
||||
return runWithFallbackVoid(ctx, "pdfengines.WriteBookmarks", multi.writeBookmarksEngines,
|
||||
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||
return engine.WriteBookmarks(ctx, logger, inputPath, bookmarks)
|
||||
},
|
||||
func(err error) error { return fmt.Errorf("write PDF bookmarks with multi PDF engines: %w", err) },
|
||||
)
|
||||
}
|
||||
|
||||
// Encrypt adds password protection to a PDF file using the first available
|
||||
// engine that supports password protection.
|
||||
func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
|
||||
tracer := gotenberg.Tracer()
|
||||
ctx, span := tracer.Start(ctx, "pdfengines.Encrypt", trace.WithSpanKind(trace.SpanKindInternal))
|
||||
defer span.End()
|
||||
|
||||
var err error
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
for _, engine := range multi.passwordEngines {
|
||||
go func(engine gotenberg.PdfEngine) {
|
||||
errChan <- engine.Encrypt(ctx, logger, inputPath, userPassword, ownerPassword)
|
||||
}(engine)
|
||||
|
||||
select {
|
||||
case protectErr := <-errChan:
|
||||
if protectErr != nil {
|
||||
err = errors.Join(err, protectErr)
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
err = fmt.Errorf("encrypt PDF using multi PDF engines: %w", err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
|
||||
return err
|
||||
func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
|
||||
return runWithFallbackVoid(ctx, "pdfengines.Encrypt", multi.passwordEngines,
|
||||
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||
return engine.Encrypt(ctx, logger, inputPath, opts)
|
||||
},
|
||||
func(err error) error { return fmt.Errorf("encrypt PDF using multi PDF engines: %w", err) },
|
||||
)
|
||||
}
|
||||
|
||||
// EmbedFiles embeds files into a PDF using the first available
|
||||
// engine that supports file embedding.
|
||||
//
|
||||
//nolint:dupl
|
||||
// EmbedFiles embeds files into a PDF using the first available engine that
|
||||
// supports file embedding.
|
||||
func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
||||
tracer := gotenberg.Tracer()
|
||||
ctx, span := tracer.Start(ctx, "pdfengines.EmbedFiles", trace.WithSpanKind(trace.SpanKindInternal))
|
||||
defer span.End()
|
||||
|
||||
var err error
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
for _, engine := range multi.embedEngines {
|
||||
go func(engine gotenberg.PdfEngine) {
|
||||
errChan <- engine.EmbedFiles(ctx, logger, filePaths, inputPath)
|
||||
}(engine)
|
||||
|
||||
select {
|
||||
case embedErr := <-errChan:
|
||||
if embedErr != nil {
|
||||
err = errors.Join(err, embedErr)
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
err = fmt.Errorf("embed files into PDF using multi PDF engines: %w", err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
|
||||
return err
|
||||
return runWithFallbackVoid(ctx, "pdfengines.EmbedFiles", multi.embedEngines,
|
||||
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||
return engine.EmbedFiles(ctx, logger, filePaths, inputPath)
|
||||
},
|
||||
func(err error) error { return fmt.Errorf("embed files into PDF using multi PDF engines: %w", err) },
|
||||
)
|
||||
}
|
||||
|
||||
// Watermark applies a watermark (behind page content) to a PDF file using the
|
||||
// first available engine that supports watermarking.
|
||||
//
|
||||
//nolint:dupl
|
||||
func (multi *multiPdfEngines) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||
tracer := gotenberg.Tracer()
|
||||
ctx, span := tracer.Start(ctx, "pdfengines.Watermark", trace.WithSpanKind(trace.SpanKindInternal))
|
||||
defer span.End()
|
||||
|
||||
var err error
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
for _, engine := range multi.watermarkEngines {
|
||||
go func(engine gotenberg.PdfEngine) {
|
||||
errChan <- engine.Watermark(ctx, logger, inputPath, stamp)
|
||||
}(engine)
|
||||
|
||||
select {
|
||||
case watermarkErr := <-errChan:
|
||||
if watermarkErr != nil {
|
||||
err = errors.Join(err, watermarkErr)
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
err = fmt.Errorf("watermark PDF with multi PDF engines: %w", err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
|
||||
return err
|
||||
return runWithFallbackVoid(ctx, "pdfengines.Watermark", multi.watermarkEngines,
|
||||
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||
return engine.Watermark(ctx, logger, inputPath, stamp)
|
||||
},
|
||||
func(err error) error { return fmt.Errorf("watermark PDF with multi PDF engines: %w", err) },
|
||||
)
|
||||
}
|
||||
|
||||
// Stamp applies a stamp (on top of page content) to a PDF file using the
|
||||
// first available engine that supports stamping.
|
||||
//
|
||||
//nolint:dupl
|
||||
// Stamp applies a stamp (on top of page content) to a PDF file using the first
|
||||
// available engine that supports stamping.
|
||||
func (multi *multiPdfEngines) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||
tracer := gotenberg.Tracer()
|
||||
ctx, span := tracer.Start(ctx, "pdfengines.Stamp", trace.WithSpanKind(trace.SpanKindInternal))
|
||||
defer span.End()
|
||||
|
||||
var err error
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
for _, engine := range multi.stampEngines {
|
||||
go func(engine gotenberg.PdfEngine) {
|
||||
errChan <- engine.Stamp(ctx, logger, inputPath, stamp)
|
||||
}(engine)
|
||||
|
||||
select {
|
||||
case stampErr := <-errChan:
|
||||
if stampErr != nil {
|
||||
err = errors.Join(err, stampErr)
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
err = fmt.Errorf("stamp PDF with multi PDF engines: %w", err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
|
||||
return err
|
||||
return runWithFallbackVoid(ctx, "pdfengines.Stamp", multi.stampEngines,
|
||||
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||
return engine.Stamp(ctx, logger, inputPath, stamp)
|
||||
},
|
||||
func(err error) error { return fmt.Errorf("stamp PDF with multi PDF engines: %w", err) },
|
||||
)
|
||||
}
|
||||
|
||||
// Rotate rotates pages of a PDF file using the first available engine that
|
||||
// supports rotation.
|
||||
func (multi *multiPdfEngines) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
|
||||
tracer := gotenberg.Tracer()
|
||||
ctx, span := tracer.Start(ctx, "pdfengines.Rotate", trace.WithSpanKind(trace.SpanKindInternal))
|
||||
defer span.End()
|
||||
|
||||
var err error
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
for _, engine := range multi.rotateEngines {
|
||||
go func(engine gotenberg.PdfEngine) {
|
||||
errChan <- engine.Rotate(ctx, logger, inputPath, angle, pages)
|
||||
}(engine)
|
||||
|
||||
select {
|
||||
case rotateErr := <-errChan:
|
||||
if rotateErr != nil {
|
||||
err = errors.Join(err, rotateErr)
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
err = fmt.Errorf("rotate PDF with multi PDF engines: %w", err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
|
||||
return err
|
||||
return runWithFallbackVoid(ctx, "pdfengines.Rotate", multi.rotateEngines,
|
||||
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||
return engine.Rotate(ctx, logger, inputPath, angle, pages)
|
||||
},
|
||||
func(err error) error { return fmt.Errorf("rotate PDF with multi PDF engines: %w", err) },
|
||||
)
|
||||
}
|
||||
|
||||
// EmbedFilesMetadata sets metadata on embedded files using the first available
|
||||
// engine that supports it.
|
||||
//
|
||||
//nolint:dupl
|
||||
func (multi *multiPdfEngines) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
|
||||
tracer := gotenberg.Tracer()
|
||||
ctx, span := tracer.Start(ctx, "pdfengines.EmbedFilesMetadata", trace.WithSpanKind(trace.SpanKindInternal))
|
||||
defer span.End()
|
||||
return runWithFallbackVoid(ctx, "pdfengines.EmbedFilesMetadata", multi.embedMetadataEngines,
|
||||
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||
return engine.EmbedFilesMetadata(ctx, logger, metadata, inputPath)
|
||||
},
|
||||
func(err error) error { return fmt.Errorf("set embeds metadata using multi PDF engines: %w", err) },
|
||||
)
|
||||
}
|
||||
|
||||
var err error
|
||||
errChan := make(chan error, 1)
|
||||
// InjectFacturXXMP injects Factur-X/ZUGFeRD XMP metadata using the first
|
||||
// available engine that supports it.
|
||||
func (multi *multiPdfEngines) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error {
|
||||
return runWithFallbackVoid(ctx, "pdfengines.InjectFacturXXMP", multi.facturXEngines,
|
||||
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||
return engine.InjectFacturXXMP(ctx, logger, facturX, inputPath)
|
||||
},
|
||||
func(err error) error { return fmt.Errorf("inject Factur-X XMP with multi PDF engines: %w", err) },
|
||||
)
|
||||
}
|
||||
|
||||
for _, engine := range multi.embedMetadataEngines {
|
||||
go func(engine gotenberg.PdfEngine) {
|
||||
errChan <- engine.EmbedFilesMetadata(ctx, logger, metadata, inputPath)
|
||||
}(engine)
|
||||
|
||||
select {
|
||||
case setErr := <-errChan:
|
||||
if setErr != nil {
|
||||
err = errors.Join(err, setErr)
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
// ReadPdfAConformance reads the PDF/A part and conformance using the first
|
||||
// available engine that supports it.
|
||||
func (multi *multiPdfEngines) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
|
||||
type pdfaConf struct {
|
||||
part string
|
||||
conformance string
|
||||
}
|
||||
|
||||
err = fmt.Errorf("set embeds metadata using multi PDF engines: %w", err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
|
||||
return err
|
||||
result, err := runWithFallback(ctx, "pdfengines.ReadPdfAConformance", multi.facturXEngines,
|
||||
func(ctx context.Context, engine gotenberg.PdfEngine) (pdfaConf, error) {
|
||||
part, conformance, err := engine.ReadPdfAConformance(ctx, logger, inputPath)
|
||||
return pdfaConf{part: part, conformance: conformance}, err
|
||||
},
|
||||
func(err error) error { return fmt.Errorf("read PDF/A conformance with multi PDF engines: %w", err) },
|
||||
)
|
||||
return result.part, result.conformance, err
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
|
||||
@@ -42,6 +42,7 @@ type PdfEngines struct {
|
||||
watermarkNames []string
|
||||
stampNames []string
|
||||
rotateNames []string
|
||||
facturXNames []string
|
||||
engines []gotenberg.PdfEngine
|
||||
disableRoutes bool
|
||||
}
|
||||
@@ -66,6 +67,7 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
|
||||
fs.StringSlice("pdfengines-watermark-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the watermark feature - empty means all")
|
||||
fs.StringSlice("pdfengines-stamp-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the stamp feature - empty means all")
|
||||
fs.StringSlice("pdfengines-rotate-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the rotate feature - empty means all")
|
||||
fs.StringSlice("pdfengines-factur-x-engines", []string{"qpdf"}, "Set the PDF engines and their order for the Factur-X XMP feature - empty means all")
|
||||
fs.Bool("pdfengines-disable-routes", false, "Disable the routes")
|
||||
|
||||
// Deprecated flags.
|
||||
@@ -99,6 +101,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
|
||||
watermarkNames := flags.MustStringSlice("pdfengines-watermark-engines")
|
||||
stampNames := flags.MustStringSlice("pdfengines-stamp-engines")
|
||||
rotateNames := flags.MustStringSlice("pdfengines-rotate-engines")
|
||||
facturXNames := flags.MustStringSlice("pdfengines-factur-x-engines")
|
||||
mod.disableRoutes = flags.MustBool("pdfengines-disable-routes")
|
||||
|
||||
engines, err := ctx.Modules(new(gotenberg.PdfEngine))
|
||||
@@ -195,6 +198,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
|
||||
mod.rotateNames = rotateNames
|
||||
}
|
||||
|
||||
mod.facturXNames = defaultNames
|
||||
if len(facturXNames) > 0 {
|
||||
mod.facturXNames = facturXNames
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -203,7 +211,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
|
||||
// actually exist.
|
||||
func (mod *PdfEngines) Validate() error {
|
||||
if len(mod.engines) == 0 {
|
||||
return errors.New("no PDF engine")
|
||||
return errors.New("no PDF engine is available; enable at least one engine module (e.g. qpdf, pdfcpu, pdftk, libreoffice-pdfengine, exiftool)")
|
||||
}
|
||||
|
||||
availableEngines := make([]string, len(mod.engines))
|
||||
@@ -250,6 +258,7 @@ func (mod *PdfEngines) Validate() error {
|
||||
findNonExistingEngines(mod.watermarkNames)
|
||||
findNonExistingEngines(mod.stampNames)
|
||||
findNonExistingEngines(mod.rotateNames)
|
||||
findNonExistingEngines(mod.facturXNames)
|
||||
|
||||
if len(nonExistingEngines) == 0 {
|
||||
return nil
|
||||
@@ -276,6 +285,7 @@ func (mod *PdfEngines) SystemMessages() []string {
|
||||
fmt.Sprintf("watermark engines - %s", strings.Join(mod.watermarkNames, " ")),
|
||||
fmt.Sprintf("stamp engines - %s", strings.Join(mod.stampNames, " ")),
|
||||
fmt.Sprintf("rotate engines - %s", strings.Join(mod.rotateNames, " ")),
|
||||
fmt.Sprintf("factur-x engines - %s", strings.Join(mod.facturXNames, " ")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,6 +320,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
|
||||
engines(mod.watermarkNames),
|
||||
engines(mod.stampNames),
|
||||
engines(mod.rotateNames),
|
||||
engines(mod.facturXNames),
|
||||
), nil
|
||||
}
|
||||
|
||||
@@ -340,6 +351,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
|
||||
watermarkRoute(engine),
|
||||
stampRoute(engine),
|
||||
rotateRoute(engine),
|
||||
facturXRoute(engine),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -467,21 +467,244 @@ func EmbedFilesMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metada
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormDataPdfEncrypt extracts encryption parameters from form data.
|
||||
func FormDataPdfEncrypt(form *api.FormData) (userPassword, ownerPassword string) {
|
||||
form.String("userPassword", &userPassword, "")
|
||||
form.String("ownerPassword", &ownerPassword, "")
|
||||
return userPassword, ownerPassword
|
||||
// FormDataPdfFacturX extracts the Factur-X parameters and the invoice XML path
|
||||
// from form data. Factur-X is requested when both facturxConformanceLevel and
|
||||
// facturxXml are provided. The embedded XML always takes the canonical
|
||||
// [gotenberg.FacturXDocumentFileName] name.
|
||||
func FormDataPdfFacturX(form *api.FormData) (gotenberg.FacturX, string) {
|
||||
var (
|
||||
facturxXmlPath string
|
||||
conformanceLevel string
|
||||
documentType string
|
||||
version string
|
||||
)
|
||||
|
||||
form.
|
||||
FacturXXml(&facturxXmlPath).
|
||||
Custom("facturxConformanceLevel", func(value string) error {
|
||||
conformanceLevel = value
|
||||
switch value {
|
||||
case "",
|
||||
gotenberg.FacturXConformanceMinimum,
|
||||
gotenberg.FacturXConformanceBasicWL,
|
||||
gotenberg.FacturXConformanceBasic,
|
||||
gotenberg.FacturXConformanceEN16931,
|
||||
gotenberg.FacturXConformanceExtended,
|
||||
gotenberg.FacturXConformanceXRechnung:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported conformance level '%s'", value)
|
||||
}
|
||||
}).
|
||||
Custom("facturxDocumentType", func(value string) error {
|
||||
if value == "" {
|
||||
documentType = gotenberg.FacturXDocumentTypeInvoice
|
||||
return nil
|
||||
}
|
||||
documentType = value
|
||||
switch value {
|
||||
case gotenberg.FacturXDocumentTypeInvoice,
|
||||
gotenberg.FacturXDocumentTypeOrder,
|
||||
gotenberg.FacturXDocumentTypeOrderResponse,
|
||||
gotenberg.FacturXDocumentTypeOrderChange:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported document type '%s'", value)
|
||||
}
|
||||
}).
|
||||
String("facturxVersion", &version, "1.0")
|
||||
|
||||
return gotenberg.FacturX{
|
||||
ConformanceLevel: conformanceLevel,
|
||||
DocumentType: documentType,
|
||||
DocumentFileName: gotenberg.FacturXDocumentFileName,
|
||||
Version: version,
|
||||
}, facturxXmlPath
|
||||
}
|
||||
|
||||
// EncryptPdfStub adds password protection to PDF files.
|
||||
func EncryptPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, userPassword, ownerPassword string, inputPaths []string) error {
|
||||
if userPassword == "" {
|
||||
// isPdfA3 reports whether the format is a PDF/A-3 variant, the only family that
|
||||
// allows the embedded files Factur-X requires.
|
||||
func isPdfA3(pdfA string) bool {
|
||||
return pdfA == gotenberg.PdfA3a || pdfA == gotenberg.PdfA3b || pdfA == gotenberg.PdfA3u
|
||||
}
|
||||
|
||||
// ValidateFacturXCompat enforces the Factur-X pairing and PDF/A-3 rules. It
|
||||
// returns a 400 error when the request is half-specified, or when an explicit
|
||||
// PDF/A format is not a PDF/A-3 variant.
|
||||
func ValidateFacturXCompat(facturX gotenberg.FacturX, facturxXmlPath string, pdfFormats gotenberg.PdfFormats) error {
|
||||
if facturX.ConformanceLevel == "" && facturxXmlPath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if facturX.ConformanceLevel == "" {
|
||||
return api.WrapError(
|
||||
errors.New("facturxConformanceLevel is required when facturxXml is provided"),
|
||||
api.NewSentinelHttpError(http.StatusBadRequest, "Invalid form data: 'facturxConformanceLevel' is required when 'facturxXml' is provided"),
|
||||
)
|
||||
}
|
||||
|
||||
if facturxXmlPath == "" {
|
||||
return api.WrapError(
|
||||
errors.New("facturxXml is required when facturxConformanceLevel is set"),
|
||||
api.NewSentinelHttpError(http.StatusBadRequest, "Invalid form data: 'facturxXml' file is required when 'facturxConformanceLevel' is set"),
|
||||
)
|
||||
}
|
||||
|
||||
if pdfFormats.PdfA != "" && !isPdfA3(pdfFormats.PdfA) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("Factur-X requires PDF/A-3, got '%s'", pdfFormats.PdfA),
|
||||
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid form data: Factur-X requires a PDF/A-3 variant (PDF/A-3a, PDF/A-3b, or PDF/A-3u), got '%s'", pdfFormats.PdfA)),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FacturXPdfFormats returns the PDF/A formats to convert to so the output meets
|
||||
// Factur-X's PDF/A-3 requirement. It returns pdfFormats unchanged when Factur-X
|
||||
// is not requested or the caller already asked for a PDF/A-3 variant. Otherwise
|
||||
// it defaults to PDF/A-3b, except for pre-existing PDFs (sourceDoc false) that
|
||||
// already carry PDF/A-3, which are left untouched.
|
||||
func FacturXPdfFormats(ctx *api.Context, engine gotenberg.PdfEngine, facturX gotenberg.FacturX, pdfFormats gotenberg.PdfFormats, sourceDoc bool, inputPaths []string) gotenberg.PdfFormats {
|
||||
if facturX.ConformanceLevel == "" || isPdfA3(pdfFormats.PdfA) {
|
||||
return pdfFormats
|
||||
}
|
||||
|
||||
if sourceDoc {
|
||||
pdfFormats.PdfA = gotenberg.PdfA3b
|
||||
return pdfFormats
|
||||
}
|
||||
|
||||
// Pre-existing PDFs: keep an already-PDF/A-3 input as-is, otherwise default
|
||||
// to PDF/A-3b.
|
||||
for _, inputPath := range inputPaths {
|
||||
part, _, err := engine.ReadPdfAConformance(ctx, ctx.Log(), inputPath)
|
||||
if err != nil {
|
||||
ctx.Log().DebugContext(ctx, fmt.Sprintf("read PDF/A conformance of '%s', assuming not PDF/A-3: %s", inputPath, err))
|
||||
part = ""
|
||||
}
|
||||
if part != "3" {
|
||||
pdfFormats.PdfA = gotenberg.PdfA3b
|
||||
return pdfFormats
|
||||
}
|
||||
}
|
||||
|
||||
return pdfFormats
|
||||
}
|
||||
|
||||
// ApplyFacturXStub turns each input PDF into a Factur-X document: it embeds the
|
||||
// CII invoice XML under the canonical name with AFRelationship "Alternative",
|
||||
// then injects the fx XMP metadata. The inputs must already be PDF/A-3 (see
|
||||
// [FacturXPdfFormats]). It is a no-op when Factur-X is not requested.
|
||||
func ApplyFacturXStub(ctx *api.Context, engine gotenberg.PdfEngine, facturX gotenberg.FacturX, facturxXmlPath string, inputPaths []string) error {
|
||||
if facturX.ConformanceLevel == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := embedFacturXXml(ctx, engine, facturxXmlPath, inputPaths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
metadata := map[string]map[string]string{
|
||||
facturX.DocumentFileName: {
|
||||
"mimeType": "text/xml",
|
||||
"relationship": "Alternative",
|
||||
},
|
||||
}
|
||||
err = EmbedFilesMetadataStub(ctx, engine, metadata, inputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set Factur-X embed metadata: %w", err)
|
||||
}
|
||||
|
||||
err = InjectFacturXXMPStub(ctx, engine, facturX, inputPaths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// embedFacturXXml embeds the Factur-X invoice XML into each PDF under the
|
||||
// canonical [gotenberg.FacturXDocumentFileName] name, regardless of the
|
||||
// uploaded file name.
|
||||
func embedFacturXXml(ctx *api.Context, engine gotenberg.PdfEngine, facturxXmlPath string, inputPaths []string) error {
|
||||
embedDir, err := ctx.CreateSubDirectory(uuid.New().String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("create Factur-X embed subdirectory: %w", err)
|
||||
}
|
||||
|
||||
canonicalPath := fmt.Sprintf("%s/%s", embedDir, gotenberg.FacturXDocumentFileName)
|
||||
err = os.Symlink(facturxXmlPath, canonicalPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("symlink Factur-X invoice XML: %w", err)
|
||||
}
|
||||
|
||||
for _, inputPath := range inputPaths {
|
||||
err = engine.EmbedFiles(ctx, ctx.Log(), []string{canonicalPath}, inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("embed Factur-X invoice XML into PDF '%s': %w", inputPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// InjectFacturXXMPStub injects Factur-X XMP metadata into PDF files. If the
|
||||
// Factur-X data is not set, it does nothing.
|
||||
func InjectFacturXXMPStub(ctx *api.Context, engine gotenberg.PdfEngine, facturX gotenberg.FacturX, inputPaths []string) error {
|
||||
if facturX.ConformanceLevel == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, inputPath := range inputPaths {
|
||||
err := engine.Encrypt(ctx, ctx.Log(), inputPath, userPassword, ownerPassword)
|
||||
err := engine.InjectFacturXXMP(ctx, ctx.Log(), facturX, inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inject Factur-X XMP into PDF '%s': %w", inputPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormDataPdfEncrypt extracts the encryption parameters and permissions from
|
||||
// form data. Permissions default to allowed.
|
||||
func FormDataPdfEncrypt(form *api.FormData) gotenberg.EncryptOptions {
|
||||
var opts gotenberg.EncryptOptions
|
||||
form.
|
||||
String("userPassword", &opts.UserPassword, "").
|
||||
String("ownerPassword", &opts.OwnerPassword, "").
|
||||
Bool("allowPrinting", &opts.Permissions.AllowPrinting, true).
|
||||
Bool("allowCopying", &opts.Permissions.AllowCopying, true).
|
||||
Bool("allowModifying", &opts.Permissions.AllowModifying, true).
|
||||
Bool("allowAnnotating", &opts.Permissions.AllowAnnotating, true).
|
||||
Bool("allowFillingForms", &opts.Permissions.AllowFillingForms, true).
|
||||
Bool("allowAssembling", &opts.Permissions.AllowAssembling, true)
|
||||
return opts
|
||||
}
|
||||
|
||||
// ValidatePdfEncryptCompat returns a 400 error when permission restrictions are
|
||||
// requested without a password to anchor them.
|
||||
func ValidatePdfEncryptCompat(opts gotenberg.EncryptOptions) error {
|
||||
if opts.Permissions.Restricted() && opts.UserPassword == "" && opts.OwnerPassword == "" {
|
||||
return api.WrapError(
|
||||
errors.New("permission restrictions require a password"),
|
||||
api.NewSentinelHttpError(http.StatusBadRequest, "Invalid form data: permission restrictions require a 'userPassword' or 'ownerPassword'"),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncryptPdfStub adds password protection and permission restrictions to PDF
|
||||
// files. It does nothing when no password is provided.
|
||||
func EncryptPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, opts gotenberg.EncryptOptions, inputPaths []string) error {
|
||||
if opts.UserPassword == "" && opts.OwnerPassword == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, inputPath := range inputPaths {
|
||||
err := engine.Encrypt(ctx, ctx.Log(), inputPath, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt PDF '%s': %w", inputPath, err)
|
||||
}
|
||||
@@ -699,7 +922,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
pdfFormats := FormDataPdfFormats(form)
|
||||
metadata := FormDataPdfMetadata(form, false)
|
||||
bookmarks := FormDataPdfBookmarks(form, false)
|
||||
userPassword, ownerPassword := FormDataPdfEncrypt(form)
|
||||
encrypt := FormDataPdfEncrypt(form)
|
||||
embedPaths := FormDataPdfEmbeds(form)
|
||||
watermark := FormDataPdfWatermark(form, false)
|
||||
watermarkFile := FormDataPdfWatermarkFile(form)
|
||||
@@ -707,6 +930,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
stampFile := FormDataPdfStampFile(form)
|
||||
angle, rotatePages := FormDataPdfRotate(form, false)
|
||||
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
||||
facturX, facturxXmlPath := FormDataPdfFacturX(form)
|
||||
|
||||
var inputPaths []string
|
||||
var flatten bool
|
||||
@@ -729,7 +953,17 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
return fmt.Errorf("validate stamp: %w", err)
|
||||
}
|
||||
|
||||
err = ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
|
||||
err = ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = ValidatePdfEncryptCompat(encrypt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = ValidateFacturXCompat(facturX, facturxXmlPath, pdfFormats)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -764,6 +998,8 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
}
|
||||
}
|
||||
|
||||
pdfFormats = FacturXPdfFormats(ctx, engine, facturX, pdfFormats, false, outputPaths)
|
||||
|
||||
outputPaths, err = ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert PDF: %w", err)
|
||||
@@ -830,7 +1066,12 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
return fmt.Errorf("set embeds metadata: %w", err)
|
||||
}
|
||||
|
||||
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
|
||||
err = ApplyFacturXStub(ctx, engine, facturX, facturxXmlPath, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply Factur-X: %w", err)
|
||||
}
|
||||
|
||||
err = EncryptPdfStub(ctx, engine, encrypt, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||
}
|
||||
@@ -858,7 +1099,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
mode := FormDataPdfSplitMode(form, true)
|
||||
pdfFormats := FormDataPdfFormats(form)
|
||||
metadata := FormDataPdfMetadata(form, false)
|
||||
userPassword, ownerPassword := FormDataPdfEncrypt(form)
|
||||
encrypt := FormDataPdfEncrypt(form)
|
||||
embedPaths := FormDataPdfEmbeds(form)
|
||||
watermark := FormDataPdfWatermark(form, false)
|
||||
watermarkFile := FormDataPdfWatermarkFile(form)
|
||||
@@ -866,6 +1107,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
stampFile := FormDataPdfStampFile(form)
|
||||
angle, rotatePages := FormDataPdfRotate(form, false)
|
||||
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
||||
facturX, facturxXmlPath := FormDataPdfFacturX(form)
|
||||
|
||||
var inputPaths []string
|
||||
var flatten bool
|
||||
@@ -886,7 +1128,17 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
return fmt.Errorf("validate stamp: %w", err)
|
||||
}
|
||||
|
||||
err = ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
|
||||
err = ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = ValidatePdfEncryptCompat(encrypt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = ValidateFacturXCompat(facturX, facturxXmlPath, pdfFormats)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -918,6 +1170,8 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
}
|
||||
}
|
||||
|
||||
pdfFormats = FacturXPdfFormats(ctx, engine, facturX, pdfFormats, false, outputPaths)
|
||||
|
||||
convertOutputPaths, err := ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert PDFs: %w", err)
|
||||
@@ -940,7 +1194,12 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
return fmt.Errorf("set embeds metadata: %w", err)
|
||||
}
|
||||
|
||||
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
|
||||
err = ApplyFacturXStub(ctx, engine, facturX, facturxXmlPath, convertOutputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply Factur-X: %w", err)
|
||||
}
|
||||
|
||||
err = EncryptPdfStub(ctx, engine, encrypt, convertOutputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||
}
|
||||
@@ -1224,20 +1483,26 @@ func encryptRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
|
||||
form := ctx.FormData()
|
||||
encrypt := FormDataPdfEncrypt(form)
|
||||
|
||||
var inputPaths []string
|
||||
var userPassword string
|
||||
var ownerPassword string
|
||||
err := form.
|
||||
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
||||
MandatoryString("userPassword", &userPassword).
|
||||
String("ownerPassword", &ownerPassword, "").
|
||||
Validate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
|
||||
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, inputPaths)
|
||||
// At least one password is required; an empty user password with an
|
||||
// owner password yields an owner-only document.
|
||||
if encrypt.UserPassword == "" && encrypt.OwnerPassword == "" {
|
||||
return api.WrapError(
|
||||
errors.New("no password provided"),
|
||||
api.NewSentinelHttpError(http.StatusBadRequest, "Invalid form data: a 'userPassword' or 'ownerPassword' is required"),
|
||||
)
|
||||
}
|
||||
|
||||
err = EncryptPdfStub(ctx, engine, encrypt, inputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||
}
|
||||
@@ -1265,6 +1530,7 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
form := ctx.FormData()
|
||||
embedPaths := FormDataPdfEmbeds(form)
|
||||
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
||||
facturX, facturxXmlPath := FormDataPdfFacturX(form)
|
||||
|
||||
var inputPaths []string
|
||||
err := form.
|
||||
@@ -1273,17 +1539,36 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
err = EmbedFilesStub(ctx, engine, embedPaths, inputPaths)
|
||||
|
||||
err = ValidateFacturXCompat(facturX, facturxXmlPath, gotenberg.PdfFormats{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Factur-X requires PDF/A-3. Convert when needed; a no-op otherwise,
|
||||
// so a plain embed request keeps its inputs untouched.
|
||||
pdfFormats := FacturXPdfFormats(ctx, engine, facturX, gotenberg.PdfFormats{}, false, inputPaths)
|
||||
outputPaths, err := ConvertStub(ctx, engine, pdfFormats, inputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert PDFs: %w", err)
|
||||
}
|
||||
|
||||
err = EmbedFilesStub(ctx, engine, embedPaths, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("embed files into PDFs: %w", err)
|
||||
}
|
||||
|
||||
err = EmbedFilesMetadataStub(ctx, engine, embedsMetadata, inputPaths)
|
||||
err = EmbedFilesMetadataStub(ctx, engine, embedsMetadata, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set embeds metadata: %w", err)
|
||||
}
|
||||
|
||||
err = ctx.AddOutputPaths(inputPaths...)
|
||||
err = ApplyFacturXStub(ctx, engine, facturX, facturxXmlPath, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply Factur-X: %w", err)
|
||||
}
|
||||
|
||||
err = ctx.AddOutputPaths(outputPaths...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add output paths: %w", err)
|
||||
}
|
||||
@@ -1413,3 +1698,61 @@ func rotateRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// facturXRoute returns an [api.Route] which turns existing PDFs into Factur-X
|
||||
// documents: it ensures PDF/A-3, embeds the CII invoice XML, and injects the fx
|
||||
// XMP metadata.
|
||||
func facturXRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
return api.Route{
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/pdfengines/factur-x",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
|
||||
form := ctx.FormData()
|
||||
pdfFormats := FormDataPdfFormats(form)
|
||||
facturX, facturxXmlPath := FormDataPdfFacturX(form)
|
||||
|
||||
var inputPaths []string
|
||||
err := form.
|
||||
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
||||
Validate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
|
||||
// Factur-X is the whole point of this route, so both fields are
|
||||
// mandatory here.
|
||||
if facturX.ConformanceLevel == "" || facturxXmlPath == "" {
|
||||
return api.WrapError(
|
||||
errors.New("facturxConformanceLevel and facturxXml are required"),
|
||||
api.NewSentinelHttpError(http.StatusBadRequest, "Invalid form data: 'facturxConformanceLevel' and 'facturxXml' are both required"),
|
||||
)
|
||||
}
|
||||
|
||||
err = ValidateFacturXCompat(facturX, facturxXmlPath, pdfFormats)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pdfFormats = FacturXPdfFormats(ctx, engine, facturX, pdfFormats, false, inputPaths)
|
||||
outputPaths, err := ConvertStub(ctx, engine, pdfFormats, inputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert PDFs: %w", err)
|
||||
}
|
||||
|
||||
err = ApplyFacturXStub(ctx, engine, facturX, facturxXmlPath, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply Factur-X: %w", err)
|
||||
}
|
||||
|
||||
err = ctx.AddOutputPaths(outputPaths...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add output paths: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
@@ -26,6 +28,9 @@ func init() {
|
||||
// interface.
|
||||
type PdfTk struct {
|
||||
binPath string
|
||||
|
||||
version string
|
||||
versionOnce sync.Once
|
||||
}
|
||||
|
||||
// Descriptor returns a [PdfTk]'s module descriptor.
|
||||
@@ -60,32 +65,58 @@ func (engine *PdfTk) Validate() error {
|
||||
|
||||
// Debug returns additional debug data.
|
||||
func (engine *PdfTk) Debug() map[string]any {
|
||||
debug := make(map[string]any)
|
||||
return map[string]any{"version": engine.detectVersion()}
|
||||
}
|
||||
|
||||
cmd := exec.Command(engine.binPath, "--version") //nolint:gosec
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
// detectVersion resolves the PDFtk version once, preferring the value captured
|
||||
// at image build time so it never spawns the PDFtk JVM at runtime. It falls
|
||||
// back to running pdftk --version for local or non-Docker builds.
|
||||
func (engine *PdfTk) detectVersion() string {
|
||||
engine.versionOnce.Do(func() {
|
||||
if v, ok := gotenberg.BuildVersion("pdftk"); ok {
|
||||
engine.version = v
|
||||
return
|
||||
}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
debug["version"] = err.Error()
|
||||
return debug
|
||||
cmd := exec.Command(engine.binPath, "--version") //nolint:gosec
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
engine.version = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
lines := bytes.SplitN(output, []byte("\n"), 2)
|
||||
if len(lines) > 0 {
|
||||
engine.version = string(lines[0])
|
||||
return
|
||||
}
|
||||
|
||||
engine.version = "Unable to determine PDFtk version"
|
||||
})
|
||||
|
||||
return engine.version
|
||||
}
|
||||
|
||||
// spanAttrs returns the client-span attributes for a PDFtk invocation: the
|
||||
// server address and the PDFtk version, plus any extra attributes. The version
|
||||
// rides on every span so a trace records which PDFtk ran the operation.
|
||||
func (engine *PdfTk) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue {
|
||||
attrs := make([]attribute.KeyValue, 0, 2+len(extra))
|
||||
attrs = append(attrs, semconv.ServerAddress(engine.binPath))
|
||||
if v := engine.detectVersion(); v != "" {
|
||||
attrs = append(attrs, attribute.String("gotenberg.pdftk.version", v))
|
||||
}
|
||||
|
||||
lines := bytes.SplitN(output, []byte("\n"), 2)
|
||||
if len(lines) > 0 {
|
||||
debug["version"] = string(lines[0])
|
||||
} else {
|
||||
debug["version"] = "Unable to determine PDFtk version"
|
||||
}
|
||||
|
||||
return debug
|
||||
return append(attrs, extra...)
|
||||
}
|
||||
|
||||
// Split splits a given PDF file.
|
||||
func (engine *PdfTk) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Split",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -132,7 +163,7 @@ func (engine *PdfTk) Split(ctx context.Context, logger *slog.Logger, mode gotenb
|
||||
func (engine *PdfTk) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Merge",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -164,7 +195,7 @@ func (engine *PdfTk) Merge(ctx context.Context, logger *slog.Logger, inputPaths
|
||||
func (engine *PdfTk) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.Flatten",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -178,7 +209,7 @@ func (engine *PdfTk) Flatten(ctx context.Context, logger *slog.Logger, inputPath
|
||||
func (engine *PdfTk) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.Convert",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -192,7 +223,7 @@ func (engine *PdfTk) Convert(ctx context.Context, logger *slog.Logger, formats g
|
||||
func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.ReadMetadata",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -206,7 +237,7 @@ func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *slog.Logger, inpu
|
||||
func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.WriteMetadata",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -220,7 +251,7 @@ func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *slog.Logger, met
|
||||
func (engine *PdfTk) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.PageCount",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -234,7 +265,7 @@ func (engine *PdfTk) PageCount(ctx context.Context, logger *slog.Logger, inputPa
|
||||
func (engine *PdfTk) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.WriteBookmarks",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -248,7 +279,7 @@ func (engine *PdfTk) WriteBookmarks(ctx context.Context, logger *slog.Logger, in
|
||||
func (engine *PdfTk) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.ReadBookmarks",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -259,21 +290,21 @@ func (engine *PdfTk) ReadBookmarks(ctx context.Context, logger *slog.Logger, inp
|
||||
}
|
||||
|
||||
// Encrypt adds password protection to a PDF file using PDFtk.
|
||||
func (engine *PdfTk) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
|
||||
func (engine *PdfTk) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Encrypt",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
if userPassword == "" {
|
||||
err := errors.New("user password cannot be empty")
|
||||
if opts.UserPassword == "" || opts.Permissions.Restricted() {
|
||||
err := gotenberg.NewPdfEngineInvalidArgs("pdftk", "owner-only encryption and permission restrictions are not supported; consider switching to another PDF engine (e.g. qpdf)")
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
if ownerPassword == userPassword || ownerPassword == "" {
|
||||
if opts.OwnerPassword == opts.UserPassword || opts.OwnerPassword == "" {
|
||||
err := gotenberg.NewPdfEngineInvalidArgs("pdftk", "both 'userPassword' and 'ownerPassword' must be provided and different. Consider switching to another PDF engine if this behavior does not work with your workflow")
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
@@ -287,8 +318,8 @@ func (engine *PdfTk) Encrypt(ctx context.Context, logger *slog.Logger, inputPath
|
||||
args = append(args, inputPath)
|
||||
args = append(args, "output", tmpPath)
|
||||
args = append(args, "encrypt_128bit")
|
||||
args = append(args, "user_pw", userPassword)
|
||||
args = append(args, "owner_pw", ownerPassword)
|
||||
args = append(args, "user_pw", opts.UserPassword)
|
||||
args = append(args, "owner_pw", opts.OwnerPassword)
|
||||
|
||||
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
||||
if err != nil {
|
||||
@@ -322,7 +353,7 @@ func (engine *PdfTk) Encrypt(ctx context.Context, logger *slog.Logger, inputPath
|
||||
func (engine *PdfTk) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.EmbedFiles",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -339,7 +370,7 @@ func (engine *PdfTk) EmbedFiles(ctx context.Context, logger *slog.Logger, filePa
|
||||
func (engine *PdfTk) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Watermark",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -389,7 +420,7 @@ func (engine *PdfTk) Watermark(ctx context.Context, logger *slog.Logger, inputPa
|
||||
func (engine *PdfTk) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Stamp",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -438,7 +469,7 @@ func (engine *PdfTk) Stamp(ctx context.Context, logger *slog.Logger, inputPath s
|
||||
func (engine *PdfTk) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Rotate",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -500,6 +531,16 @@ func (engine *PdfTk) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger
|
||||
return fmt.Errorf("set embeds metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// InjectFacturXXMP is not available in this implementation.
|
||||
func (engine *PdfTk) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error {
|
||||
return fmt.Errorf("inject Factur-X XMP with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// ReadPdfAConformance is not available in this implementation.
|
||||
func (engine *PdfTk) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
|
||||
return "", "", fmt.Errorf("read PDF/A conformance with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*PdfTk)(nil)
|
||||
|
||||
76
pkg/modules/qpdf/encrypt_test.go
Normal file
76
pkg/modules/qpdf/encrypt_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package qpdf
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestQpdfPermissionArgs(t *testing.T) {
|
||||
allAllowed := gotenberg.PdfPermissions{
|
||||
AllowPrinting: true,
|
||||
AllowCopying: true,
|
||||
AllowModifying: true,
|
||||
AllowAnnotating: true,
|
||||
AllowFillingForms: true,
|
||||
AllowAssembling: true,
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
perms gotenberg.PdfPermissions
|
||||
expect []string
|
||||
}{
|
||||
{
|
||||
scenario: "all allowed yields no flags",
|
||||
perms: allAllowed,
|
||||
expect: nil,
|
||||
},
|
||||
{
|
||||
scenario: "printing denied",
|
||||
perms: gotenberg.PdfPermissions{
|
||||
AllowPrinting: false,
|
||||
AllowCopying: true,
|
||||
AllowModifying: true,
|
||||
AllowAnnotating: true,
|
||||
AllowFillingForms: true,
|
||||
AllowAssembling: true,
|
||||
},
|
||||
expect: []string{
|
||||
"--print=none",
|
||||
"--extract=y",
|
||||
"--modify-other=y",
|
||||
"--annotate=y",
|
||||
"--form=y",
|
||||
"--assemble=y",
|
||||
},
|
||||
},
|
||||
{
|
||||
scenario: "copying denied",
|
||||
perms: gotenberg.PdfPermissions{
|
||||
AllowPrinting: true,
|
||||
AllowCopying: false,
|
||||
AllowModifying: true,
|
||||
AllowAnnotating: true,
|
||||
AllowFillingForms: true,
|
||||
AllowAssembling: true,
|
||||
},
|
||||
expect: []string{
|
||||
"--print=full",
|
||||
"--extract=n",
|
||||
"--modify-other=y",
|
||||
"--annotate=y",
|
||||
"--form=y",
|
||||
"--assemble=y",
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
got := qpdfPermissionArgs(tc.perms)
|
||||
if !reflect.DeepEqual(got, tc.expect) {
|
||||
t.Errorf("expected %v but got %v", tc.expect, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
299
pkg/modules/qpdf/facturx_test.go
Normal file
299
pkg/modules/qpdf/facturx_test.go
Normal file
@@ -0,0 +1,299 @@
|
||||
package qpdf
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestValidateFacturX(t *testing.T) {
|
||||
valid := gotenberg.FacturX{
|
||||
ConformanceLevel: gotenberg.FacturXConformanceEN16931,
|
||||
DocumentType: gotenberg.FacturXDocumentTypeInvoice,
|
||||
DocumentFileName: "factur-x.xml",
|
||||
Version: "1.0",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(f *gotenberg.FacturX)
|
||||
wantError bool
|
||||
}{
|
||||
{name: "valid", mutate: func(*gotenberg.FacturX) {}},
|
||||
{
|
||||
name: "valid BASIC WL conformance",
|
||||
mutate: func(f *gotenberg.FacturX) { f.ConformanceLevel = gotenberg.FacturXConformanceBasicWL },
|
||||
},
|
||||
{
|
||||
name: "valid ORDER document type",
|
||||
mutate: func(f *gotenberg.FacturX) { f.DocumentType = gotenberg.FacturXDocumentTypeOrder },
|
||||
},
|
||||
{
|
||||
name: "unsupported conformance level",
|
||||
mutate: func(f *gotenberg.FacturX) { f.ConformanceLevel = "FOO" },
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "empty conformance level",
|
||||
mutate: func(f *gotenberg.FacturX) { f.ConformanceLevel = "" },
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "unsupported document type",
|
||||
mutate: func(f *gotenberg.FacturX) { f.DocumentType = "RECEIPT" },
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "empty document file name",
|
||||
mutate: func(f *gotenberg.FacturX) { f.DocumentFileName = "" },
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "empty version",
|
||||
mutate: func(f *gotenberg.FacturX) { f.Version = "" },
|
||||
wantError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
facturX := valid
|
||||
tt.mutate(&facturX)
|
||||
|
||||
err := validateFacturX(facturX)
|
||||
if tt.wantError {
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
}
|
||||
if !errors.Is(err, gotenberg.ErrPdfFacturXValueNotSupported) {
|
||||
t.Errorf("expected ErrPdfFacturXValueNotSupported, got %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindMetadataStream(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantKey string
|
||||
wantXMP string
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "metadata stream found",
|
||||
// "PFhtcD4=" is base64 for "<xmp>".
|
||||
input: `{"qpdf":[{},{"obj:1 0 R":{"value":{"/Type":"/Catalog","/Metadata":"4 0 R"}},"obj:4 0 R":{"stream":{"dict":{"/Type":"/Metadata","/Subtype":"/XML"},"data":"PHhtcD4="}}}]}`,
|
||||
wantKey: "obj:4 0 R",
|
||||
wantXMP: "<xmp>",
|
||||
},
|
||||
{
|
||||
name: "catalog without metadata reference",
|
||||
input: `{"qpdf":[{},{"obj:1 0 R":{"value":{"/Type":"/Catalog"}}}]}`,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "metadata object missing",
|
||||
input: `{"qpdf":[{},{"obj:1 0 R":{"value":{"/Type":"/Catalog","/Metadata":"4 0 R"}}}]}`,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "metadata object is not a stream",
|
||||
input: `{"qpdf":[{},{"obj:1 0 R":{"value":{"/Type":"/Catalog","/Metadata":"4 0 R"}},"obj:4 0 R":{"value":{"/Type":"/Metadata"}}}]}`,
|
||||
wantError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
objects, err := parsePdfObjects([]byte(tt.input))
|
||||
if err != nil {
|
||||
t.Fatalf("parse objects: %v", err)
|
||||
}
|
||||
|
||||
key, dict, xmp, err := findMetadataStream(objects)
|
||||
if tt.wantError {
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if key != tt.wantKey {
|
||||
t.Errorf("key = %q, want %q", key, tt.wantKey)
|
||||
}
|
||||
if xmp != tt.wantXMP {
|
||||
t.Errorf("xmp = %q, want %q", xmp, tt.wantXMP)
|
||||
}
|
||||
if dict == nil {
|
||||
t.Error("expected a non-nil dict")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectFacturXIntoXMP(t *testing.T) {
|
||||
facturX := gotenberg.FacturX{
|
||||
ConformanceLevel: gotenberg.FacturXConformanceEN16931,
|
||||
DocumentType: gotenberg.FacturXDocumentTypeInvoice,
|
||||
DocumentFileName: "factur-x.xml",
|
||||
Version: "1.0",
|
||||
}
|
||||
|
||||
// The packet a LibreOffice PDF/A-3b export produces: no pdfaExtension bag.
|
||||
libreOfficeXMP := `<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||||
<x:xmpmeta xmlns:x="adobe:ns:meta/">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/">
|
||||
<pdfaid:part>3</pdfaid:part>
|
||||
<pdfaid:conformance>B</pdfaid:conformance>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
<?xpacket end="w"?>`
|
||||
|
||||
t.Run("creates extension schema when bag absent", func(t *testing.T) {
|
||||
got, changed := injectFacturXIntoXMP(libreOfficeXMP, facturX)
|
||||
if !changed {
|
||||
t.Fatal("expected changed = true")
|
||||
}
|
||||
assertContains(t, got, facturXNamespaceURI)
|
||||
assertContains(t, got, "<fx:ConformanceLevel>EN 16931</fx:ConformanceLevel>")
|
||||
assertContains(t, got, "<fx:DocumentType>INVOICE</fx:DocumentType>")
|
||||
assertContains(t, got, "<fx:DocumentFileName>factur-x.xml</fx:DocumentFileName>")
|
||||
assertContains(t, got, "<fx:Version>1.0</fx:Version>")
|
||||
assertContains(t, got, "pdfaExtension:schemas")
|
||||
assertContains(t, got, "http://www.aiim.org/pdfa/ns/extension/")
|
||||
// The fx blocks must land inside the RDF container.
|
||||
if strings.Index(got, facturXNamespaceURI) > strings.LastIndex(got, "</rdf:RDF>") {
|
||||
t.Error("fx content injected outside the rdf:RDF container")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("idempotent when fx already present", func(t *testing.T) {
|
||||
once, _ := injectFacturXIntoXMP(libreOfficeXMP, facturX)
|
||||
twice, changed := injectFacturXIntoXMP(once, facturX)
|
||||
if changed {
|
||||
t.Error("expected changed = false on a packet that already declares fx")
|
||||
}
|
||||
if twice != once {
|
||||
t.Error("expected the packet to be left untouched")
|
||||
}
|
||||
if strings.Count(twice, "<pdfaExtension:schemas>") != 1 {
|
||||
t.Error("expected exactly one pdfaExtension:schemas declaration")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appends entry to an existing bag", func(t *testing.T) {
|
||||
withBag := `<x:xmpmeta xmlns:x="adobe:ns:meta/">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#" xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#">
|
||||
<pdfaExtension:schemas>
|
||||
<rdf:Bag>
|
||||
<rdf:li rdf:parseType="Resource"><pdfaSchema:prefix>other</pdfaSchema:prefix></rdf:li>
|
||||
</rdf:Bag>
|
||||
</pdfaExtension:schemas>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>`
|
||||
got, changed := injectFacturXIntoXMP(withBag, facturX)
|
||||
if !changed {
|
||||
t.Fatal("expected changed = true")
|
||||
}
|
||||
assertContains(t, got, facturXNamespaceURI)
|
||||
if strings.Count(got, "<pdfaExtension:schemas>") != 1 {
|
||||
t.Error("expected the existing pdfaExtension:schemas bag to be reused, not duplicated")
|
||||
}
|
||||
if strings.Count(got, "<pdfaSchema:prefix>") != 2 {
|
||||
t.Error("expected both the existing and the fx schema entries")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no rdf:RDF anchor leaves packet unchanged", func(t *testing.T) {
|
||||
got, changed := injectFacturXIntoXMP("not an xmp packet", facturX)
|
||||
if changed {
|
||||
t.Error("expected changed = false")
|
||||
}
|
||||
if got != "not an xmp packet" {
|
||||
t.Error("expected the packet to be left untouched")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("escapes runtime values", func(t *testing.T) {
|
||||
escaped := facturX
|
||||
escaped.DocumentFileName = "a&b<c>.xml"
|
||||
got, _ := injectFacturXIntoXMP(libreOfficeXMP, escaped)
|
||||
assertContains(t, got, "a&b<c>.xml")
|
||||
if strings.Contains(got, "<fx:DocumentFileName>a&b<c>.xml") {
|
||||
t.Error("expected the document file name to be XML-escaped")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func assertContains(t *testing.T, haystack, needle string) {
|
||||
t.Helper()
|
||||
if !strings.Contains(haystack, needle) {
|
||||
t.Errorf("expected output to contain %q", needle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePdfAId(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
xmp string
|
||||
expectPart string
|
||||
expectConform string
|
||||
}{
|
||||
{
|
||||
scenario: "element form",
|
||||
xmp: `<rdf:Description><pdfaid:part>3</pdfaid:part><pdfaid:conformance>B</pdfaid:conformance></rdf:Description>`,
|
||||
expectPart: "3",
|
||||
expectConform: "B",
|
||||
},
|
||||
{
|
||||
scenario: "attribute form",
|
||||
xmp: `<rdf:Description pdfaid:part="3" pdfaid:conformance="U"></rdf:Description>`,
|
||||
expectPart: "3",
|
||||
expectConform: "U",
|
||||
},
|
||||
{
|
||||
scenario: "part 2",
|
||||
xmp: `<pdfaid:part>2</pdfaid:part><pdfaid:conformance>B</pdfaid:conformance>`,
|
||||
expectPart: "2",
|
||||
expectConform: "B",
|
||||
},
|
||||
{
|
||||
scenario: "no pdfa identification",
|
||||
xmp: `<rdf:Description><dc:title>foo</dc:title></rdf:Description>`,
|
||||
expectPart: "",
|
||||
expectConform: "",
|
||||
},
|
||||
{
|
||||
scenario: "empty packet",
|
||||
xmp: "",
|
||||
expectPart: "",
|
||||
expectConform: "",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
part, conformance := parsePdfAId(tc.xmp)
|
||||
if part != tc.expectPart {
|
||||
t.Errorf("expected part %q but got %q", tc.expectPart, part)
|
||||
}
|
||||
if conformance != tc.expectConform {
|
||||
t.Errorf("expected conformance %q but got %q", tc.expectConform, conformance)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,21 @@ package qpdf
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
@@ -29,6 +34,9 @@ func init() {
|
||||
type QPdf struct {
|
||||
binPath string
|
||||
globalArgs []string
|
||||
|
||||
version string
|
||||
versionOnce sync.Once
|
||||
}
|
||||
|
||||
// Descriptor returns a [QPdf]'s module descriptor.
|
||||
@@ -43,7 +51,7 @@ func (engine *QPdf) Descriptor() gotenberg.ModuleDescriptor {
|
||||
func (engine *QPdf) Provision(ctx *gotenberg.Context) error {
|
||||
binPath, ok := os.LookupEnv("QPDF_BIN_PATH")
|
||||
if !ok {
|
||||
return errors.New("QPDF_BIN_PATH environment variable is not set")
|
||||
return errors.New("QPDF_BIN_PATH environment variable is not set; set it to the absolute path of the qpdf binary")
|
||||
}
|
||||
|
||||
engine.binPath = binPath
|
||||
@@ -65,32 +73,58 @@ func (engine *QPdf) Validate() error {
|
||||
|
||||
// Debug returns additional debug data.
|
||||
func (engine *QPdf) Debug() map[string]any {
|
||||
debug := make(map[string]any)
|
||||
return map[string]any{"version": engine.detectVersion()}
|
||||
}
|
||||
|
||||
cmd := exec.Command(engine.binPath, "--version") //nolint:gosec
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
// detectVersion resolves the qpdf version once, preferring the value captured
|
||||
// at image build time so it never spawns qpdf at runtime. It falls back to
|
||||
// running qpdf --version for local or non-Docker builds.
|
||||
func (engine *QPdf) detectVersion() string {
|
||||
engine.versionOnce.Do(func() {
|
||||
if v, ok := gotenberg.BuildVersion("qpdf"); ok {
|
||||
engine.version = v
|
||||
return
|
||||
}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
debug["version"] = err.Error()
|
||||
return debug
|
||||
cmd := exec.Command(engine.binPath, "--version") //nolint:gosec
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
engine.version = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
lines := bytes.SplitN(output, []byte("\n"), 2)
|
||||
if len(lines) > 0 {
|
||||
engine.version = string(lines[0])
|
||||
return
|
||||
}
|
||||
|
||||
engine.version = "Unable to determine QPDF version"
|
||||
})
|
||||
|
||||
return engine.version
|
||||
}
|
||||
|
||||
// spanAttrs returns the client-span attributes for a qpdf invocation: the
|
||||
// server address and the qpdf version, plus any extra attributes. The version
|
||||
// rides on every span so a trace records which qpdf ran the operation.
|
||||
func (engine *QPdf) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue {
|
||||
attrs := make([]attribute.KeyValue, 0, 2+len(extra))
|
||||
attrs = append(attrs, semconv.ServerAddress(engine.binPath))
|
||||
if v := engine.detectVersion(); v != "" {
|
||||
attrs = append(attrs, attribute.String("gotenberg.qpdf.version", v))
|
||||
}
|
||||
|
||||
lines := bytes.SplitN(output, []byte("\n"), 2)
|
||||
if len(lines) > 0 {
|
||||
debug["version"] = string(lines[0])
|
||||
} else {
|
||||
debug["version"] = "Unable to determine QPDF version"
|
||||
}
|
||||
|
||||
return debug
|
||||
return append(attrs, extra...)
|
||||
}
|
||||
|
||||
// Split splits a given PDF file.
|
||||
func (engine *QPdf) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Split",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -140,7 +174,7 @@ func (engine *QPdf) Split(ctx context.Context, logger *slog.Logger, mode gotenbe
|
||||
func (engine *QPdf) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Merge",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -176,7 +210,7 @@ func (engine *QPdf) Merge(ctx context.Context, logger *slog.Logger, inputPaths [
|
||||
func (engine *QPdf) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Flatten",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -211,7 +245,7 @@ func (engine *QPdf) Flatten(ctx context.Context, logger *slog.Logger, inputPath
|
||||
func (engine *QPdf) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Convert",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -225,7 +259,7 @@ func (engine *QPdf) Convert(ctx context.Context, logger *slog.Logger, formats go
|
||||
func (engine *QPdf) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadMetadata",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -239,7 +273,7 @@ func (engine *QPdf) ReadMetadata(ctx context.Context, logger *slog.Logger, input
|
||||
func (engine *QPdf) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.WriteMetadata",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -253,7 +287,7 @@ func (engine *QPdf) WriteMetadata(ctx context.Context, logger *slog.Logger, meta
|
||||
func (engine *QPdf) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.PageCount",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -267,7 +301,7 @@ func (engine *QPdf) PageCount(ctx context.Context, logger *slog.Logger, inputPat
|
||||
func (engine *QPdf) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.WriteBookmarks",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -281,7 +315,7 @@ func (engine *QPdf) WriteBookmarks(ctx context.Context, logger *slog.Logger, inp
|
||||
func (engine *QPdf) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadBookmarks",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -292,29 +326,63 @@ func (engine *QPdf) ReadBookmarks(ctx context.Context, logger *slog.Logger, inpu
|
||||
}
|
||||
|
||||
// Encrypt adds password protection to a PDF file using QPDF.
|
||||
func (engine *QPdf) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
|
||||
// qpdfPermissionArgs maps PDF permissions to QPDF --encrypt restriction flags.
|
||||
// It returns nil when every permission is allowed, matching QPDF's default
|
||||
// (all actions permitted).
|
||||
func qpdfPermissionArgs(p gotenberg.PdfPermissions) []string {
|
||||
if !p.Restricted() {
|
||||
return nil
|
||||
}
|
||||
|
||||
yn := func(allowed bool) string {
|
||||
if allowed {
|
||||
return "y"
|
||||
}
|
||||
return "n"
|
||||
}
|
||||
|
||||
print := "full"
|
||||
if !p.AllowPrinting {
|
||||
print = "none"
|
||||
}
|
||||
|
||||
return []string{
|
||||
"--print=" + print,
|
||||
"--extract=" + yn(p.AllowCopying),
|
||||
"--modify-other=" + yn(p.AllowModifying),
|
||||
"--annotate=" + yn(p.AllowAnnotating),
|
||||
"--form=" + yn(p.AllowFillingForms),
|
||||
"--assemble=" + yn(p.AllowAssembling),
|
||||
}
|
||||
}
|
||||
|
||||
func (engine *QPdf) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Encrypt",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
if userPassword == "" {
|
||||
err := errors.New("user password cannot be empty")
|
||||
ownerPassword := opts.OwnerPassword
|
||||
if ownerPassword == "" {
|
||||
ownerPassword = opts.UserPassword
|
||||
}
|
||||
|
||||
// An empty user password is allowed: it produces an owner-only document.
|
||||
if opts.UserPassword == "" && ownerPassword == "" {
|
||||
err := errors.New("at least a user or owner password is required")
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
if ownerPassword == "" {
|
||||
ownerPassword = userPassword
|
||||
}
|
||||
|
||||
args := make([]string, 0, 7+len(engine.globalArgs))
|
||||
args := make([]string, 0, 14+len(engine.globalArgs))
|
||||
args = append(args, inputPath)
|
||||
args = append(args, engine.globalArgs...)
|
||||
args = append(args, "--replace-input")
|
||||
args = append(args, "--encrypt", userPassword, ownerPassword, "256", "--")
|
||||
args = append(args, "--encrypt", opts.UserPassword, ownerPassword, "256")
|
||||
args = append(args, qpdfPermissionArgs(opts.Permissions)...)
|
||||
args = append(args, "--")
|
||||
|
||||
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
||||
if err != nil {
|
||||
@@ -340,7 +408,7 @@ func (engine *QPdf) Encrypt(ctx context.Context, logger *slog.Logger, inputPath,
|
||||
func (engine *QPdf) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.EmbedFiles",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -357,7 +425,7 @@ func (engine *QPdf) EmbedFiles(ctx context.Context, logger *slog.Logger, filePat
|
||||
func (engine *QPdf) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.EmbedFilesMetadata",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -527,8 +595,10 @@ func patchCatalogAF(catalogRef string, catalogValue map[string]any, filespecRefs
|
||||
}
|
||||
|
||||
// writeAndApplyUpdate marshals the update objects as QPDF JSON v2, writes
|
||||
// them to a temp file, and applies the update via --update-from-json.
|
||||
func (engine *QPdf) writeAndApplyUpdate(ctx context.Context, logger *slog.Logger, inputPath string, updateObjects map[string]any) error {
|
||||
// them to a temp file, and applies the update via --update-from-json. extraArgs
|
||||
// are appended to the QPDF command (e.g., --json-stream-data=inline when the
|
||||
// update replaces stream data).
|
||||
func (engine *QPdf) writeAndApplyUpdate(ctx context.Context, logger *slog.Logger, inputPath string, updateObjects map[string]any, extraArgs ...string) error {
|
||||
updateJSON := map[string]any{
|
||||
"qpdf": []any{
|
||||
map[string]any{
|
||||
@@ -560,9 +630,10 @@ func (engine *QPdf) writeAndApplyUpdate(ctx context.Context, logger *slog.Logger
|
||||
return fmt.Errorf("close temp file: %w", err)
|
||||
}
|
||||
|
||||
updateArgs := make([]string, 0, 5+len(engine.globalArgs))
|
||||
updateArgs := make([]string, 0, 5+len(engine.globalArgs)+len(extraArgs))
|
||||
updateArgs = append(updateArgs, inputPath)
|
||||
updateArgs = append(updateArgs, engine.globalArgs...)
|
||||
updateArgs = append(updateArgs, extraArgs...)
|
||||
updateArgs = append(updateArgs, "--newline-before-endstream")
|
||||
updateArgs = append(updateArgs, "--update-from-json="+tmpFile.Name())
|
||||
updateArgs = append(updateArgs, "--replace-input")
|
||||
@@ -635,11 +706,396 @@ func stripQpdfStringPrefix(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// facturXNamespaceURI is the Factur-X/ZUGFeRD XMP namespace required by strict
|
||||
// validators.
|
||||
const facturXNamespaceURI = "urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#"
|
||||
|
||||
// InjectFacturXXMP injects Factur-X/ZUGFeRD XMP metadata into the document-level
|
||||
// XMP packet (Catalog /Metadata stream) of a PDF/A-3 using QPDF's JSON
|
||||
// manipulation. It reads the existing XMP packet, splices in the fx
|
||||
// rdf:Description plus the PDF/A extension-schema declaration, and writes the
|
||||
// stream back uncompressed so the document stays PDF/A-valid.
|
||||
//
|
||||
// It assumes the input already carries a Catalog /Metadata stream (always true
|
||||
// for a LibreOffice PDF/A export). The injection is idempotent: a packet that
|
||||
// already declares the fx namespace is left untouched.
|
||||
func (engine *QPdf) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.InjectFacturXXMP",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(engine.spanAttrs(
|
||||
attribute.String("gotenberg.facturx.conformance_level", facturX.ConformanceLevel),
|
||||
attribute.String("gotenberg.facturx.document_type", facturX.DocumentType),
|
||||
)...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
err := validateFacturX(facturX)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
logger.DebugContext(ctx, fmt.Sprintf("injecting Factur-X XMP into %s with QPDF", inputPath))
|
||||
|
||||
args := append([]string{inputPath}, engine.globalArgs...)
|
||||
args = append(args, "--newline-before-endstream", "--json-output", "--json-stream-data=inline")
|
||||
|
||||
output, err := engine.execCaptureOutput(ctx, args...)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("get PDF JSON with QPDF: %w", err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
objects, err := parsePdfObjects(output)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
metaKey, metaDict, xmp, err := findMetadataStream(objects)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("locate XMP metadata stream: %w", err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
newXmp, changed := injectFacturXIntoXMP(xmp, facturX)
|
||||
if !changed {
|
||||
logger.DebugContext(ctx, "Factur-X XMP already present, skipping injection")
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// PDF/A requires the metadata stream to be uncompressed and unfiltered. We
|
||||
// provide the decoded XMP as the new stream data, so any existing filter
|
||||
// must be dropped and the length left for QPDF to recompute.
|
||||
delete(metaDict, "/Filter")
|
||||
delete(metaDict, "/DecodeParms")
|
||||
delete(metaDict, "/Length")
|
||||
|
||||
updateObjects := map[string]any{
|
||||
metaKey: map[string]any{
|
||||
"stream": map[string]any{
|
||||
"dict": metaDict,
|
||||
"data": base64.StdEncoding.EncodeToString([]byte(newXmp)),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err = engine.writeAndApplyUpdate(ctx, logger, inputPath, updateObjects, "--json-stream-data=inline")
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadPdfAConformance reads the PDF/A part and conformance from the
|
||||
// document-level XMP packet (pdfaid:part and pdfaid:conformance) using QPDF's
|
||||
// JSON output. It returns empty strings when the document carries no XMP
|
||||
// metadata stream or no PDF/A identification.
|
||||
func (engine *QPdf) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadPdfAConformance",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
logger.DebugContext(ctx, fmt.Sprintf("reading PDF/A conformance from %s with QPDF", inputPath))
|
||||
|
||||
args := append([]string{inputPath}, engine.globalArgs...)
|
||||
args = append(args, "--json-output", "--json-stream-data=inline")
|
||||
|
||||
output, err := engine.execCaptureOutput(ctx, args...)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("get PDF JSON with QPDF: %w", err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
objects, err := parsePdfObjects(output)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
_, _, xmp, err := findMetadataStream(objects)
|
||||
if err != nil {
|
||||
// No XMP metadata stream means no PDF/A identification.
|
||||
logger.DebugContext(ctx, fmt.Sprintf("no XMP metadata stream in %s: %s", inputPath, err))
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
part, conformance := parsePdfAId(xmp)
|
||||
span.SetAttributes(
|
||||
attribute.String("gotenberg.pdfa.part", part),
|
||||
attribute.String("gotenberg.pdfa.conformance", conformance),
|
||||
)
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return part, conformance, nil
|
||||
}
|
||||
|
||||
var (
|
||||
pdfaIdPartRe = regexp.MustCompile(`pdfaid:part[\s>="']*([0-9]+)`)
|
||||
pdfaIdConformanceRe = regexp.MustCompile(`pdfaid:conformance[\s>="']*([A-Za-z]+)`)
|
||||
)
|
||||
|
||||
// parsePdfAId extracts the PDF/A part and conformance from an XMP packet. It
|
||||
// handles both the element (<pdfaid:part>3</pdfaid:part>) and attribute
|
||||
// (pdfaid:part="3") serializations.
|
||||
func parsePdfAId(xmp string) (part string, conformance string) {
|
||||
if m := pdfaIdPartRe.FindStringSubmatch(xmp); m != nil {
|
||||
part = m[1]
|
||||
}
|
||||
if m := pdfaIdConformanceRe.FindStringSubmatch(xmp); m != nil {
|
||||
conformance = m[1]
|
||||
}
|
||||
return part, conformance
|
||||
}
|
||||
|
||||
// validateFacturX checks the Factur-X fields against the supported values.
|
||||
func validateFacturX(facturX gotenberg.FacturX) error {
|
||||
switch facturX.ConformanceLevel {
|
||||
case gotenberg.FacturXConformanceMinimum,
|
||||
gotenberg.FacturXConformanceBasicWL,
|
||||
gotenberg.FacturXConformanceBasic,
|
||||
gotenberg.FacturXConformanceEN16931,
|
||||
gotenberg.FacturXConformanceExtended,
|
||||
gotenberg.FacturXConformanceXRechnung:
|
||||
default:
|
||||
return fmt.Errorf("conformance level '%s': %w", facturX.ConformanceLevel, gotenberg.ErrPdfFacturXValueNotSupported)
|
||||
}
|
||||
|
||||
switch facturX.DocumentType {
|
||||
case gotenberg.FacturXDocumentTypeInvoice,
|
||||
gotenberg.FacturXDocumentTypeOrder,
|
||||
gotenberg.FacturXDocumentTypeOrderResponse,
|
||||
gotenberg.FacturXDocumentTypeOrderChange:
|
||||
default:
|
||||
return fmt.Errorf("document type '%s': %w", facturX.DocumentType, gotenberg.ErrPdfFacturXValueNotSupported)
|
||||
}
|
||||
|
||||
if facturX.DocumentFileName == "" {
|
||||
return fmt.Errorf("document file name is empty: %w", gotenberg.ErrPdfFacturXValueNotSupported)
|
||||
}
|
||||
|
||||
if facturX.Version == "" {
|
||||
return fmt.Errorf("version is empty: %w", gotenberg.ErrPdfFacturXValueNotSupported)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// findMetadataStream locates the document-level XMP metadata stream referenced
|
||||
// by the Catalog /Metadata entry. It returns the object key (e.g. "obj:4 0 R"),
|
||||
// the stream dict, and the decoded XMP packet.
|
||||
func findMetadataStream(objects map[string]json.RawMessage) (string, map[string]any, string, error) {
|
||||
var metadataRef string
|
||||
for _, raw := range objects {
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
valueRaw, ok := obj["value"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
var value map[string]any
|
||||
if err := json.Unmarshal(valueRaw, &value); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if typeVal, _ := value["/Type"].(string); typeVal == "/Catalog" {
|
||||
metadataRef, _ = value["/Metadata"].(string)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if metadataRef == "" {
|
||||
return "", nil, "", errors.New("no /Metadata reference in the catalog")
|
||||
}
|
||||
|
||||
// References in values use the "4 0 R" form; object keys use "obj:4 0 R".
|
||||
objKey := "obj:" + metadataRef
|
||||
raw, ok := objects[objKey]
|
||||
if !ok {
|
||||
return "", nil, "", fmt.Errorf("metadata object '%s' not found", objKey)
|
||||
}
|
||||
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
return "", nil, "", fmt.Errorf("unmarshal metadata object: %w", err)
|
||||
}
|
||||
|
||||
streamRaw, ok := obj["stream"]
|
||||
if !ok {
|
||||
return "", nil, "", errors.New("metadata object is not a stream")
|
||||
}
|
||||
|
||||
var stream struct {
|
||||
Dict map[string]any `json:"dict"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(streamRaw, &stream); err != nil {
|
||||
return "", nil, "", fmt.Errorf("unmarshal metadata stream: %w", err)
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(stream.Data)
|
||||
if err != nil {
|
||||
return "", nil, "", fmt.Errorf("decode metadata stream data: %w", err)
|
||||
}
|
||||
|
||||
dict := stream.Dict
|
||||
if dict == nil {
|
||||
dict = make(map[string]any)
|
||||
}
|
||||
|
||||
return objKey, dict, string(decoded), nil
|
||||
}
|
||||
|
||||
// injectFacturXIntoXMP splices the fx rdf:Description and the PDF/A
|
||||
// extension-schema declaration into an XMP packet. It returns the new packet and
|
||||
// whether a change was made (false when the fx namespace is already present).
|
||||
func injectFacturXIntoXMP(xmp string, facturX gotenberg.FacturX) (string, bool) {
|
||||
if strings.Contains(xmp, facturXNamespaceURI) {
|
||||
return xmp, false
|
||||
}
|
||||
|
||||
anchor := strings.LastIndex(xmp, "</rdf:RDF>")
|
||||
if anchor == -1 {
|
||||
return xmp, false
|
||||
}
|
||||
|
||||
insert := facturXDescription(facturX)
|
||||
|
||||
if strings.Contains(xmp, "pdfaExtension:schemas") {
|
||||
// An extension-schema bag already exists (e.g. emitted by another tool):
|
||||
// splice the fx Description, then append the fx schema entry into the bag.
|
||||
spliced := xmp[:anchor] + insert + xmp[anchor:]
|
||||
return injectSchemaIntoExistingBag(spliced), true
|
||||
}
|
||||
|
||||
// No extension-schema bag yet (the LibreOffice PDF/A case): create the whole
|
||||
// container alongside the fx Description.
|
||||
insert += facturXExtensionSchema()
|
||||
return xmp[:anchor] + insert + xmp[anchor:], true
|
||||
}
|
||||
|
||||
// injectSchemaIntoExistingBag appends the fx schema entry into an existing
|
||||
// pdfaExtension:schemas bag.
|
||||
func injectSchemaIntoExistingBag(xmp string) string {
|
||||
mi := strings.Index(xmp, "pdfaExtension:schemas")
|
||||
if mi == -1 {
|
||||
return xmp
|
||||
}
|
||||
|
||||
bag := strings.Index(xmp[mi:], "<rdf:Bag")
|
||||
if bag == -1 {
|
||||
return xmp
|
||||
}
|
||||
|
||||
gt := strings.Index(xmp[mi+bag:], ">")
|
||||
if gt == -1 {
|
||||
return xmp
|
||||
}
|
||||
|
||||
pos := mi + bag + gt + 1
|
||||
return xmp[:pos] + "\n" + facturXSchemaLi() + xmp[pos:]
|
||||
}
|
||||
|
||||
// facturXDescription builds the fx rdf:Description carrying the runtime values.
|
||||
func facturXDescription(facturX gotenberg.FacturX) string {
|
||||
return fmt.Sprintf(` <rdf:Description rdf:about="" xmlns:fx="%s">
|
||||
<fx:DocumentType>%s</fx:DocumentType>
|
||||
<fx:DocumentFileName>%s</fx:DocumentFileName>
|
||||
<fx:Version>%s</fx:Version>
|
||||
<fx:ConformanceLevel>%s</fx:ConformanceLevel>
|
||||
</rdf:Description>
|
||||
`,
|
||||
facturXNamespaceURI,
|
||||
xmlEscape(facturX.DocumentType),
|
||||
xmlEscape(facturX.DocumentFileName),
|
||||
xmlEscape(facturX.Version),
|
||||
xmlEscape(facturX.ConformanceLevel),
|
||||
)
|
||||
}
|
||||
|
||||
// facturXExtensionSchema builds the rdf:Description that declares the PDF/A
|
||||
// extension schema for the fx namespace, including the namespace declarations.
|
||||
func facturXExtensionSchema() string {
|
||||
return fmt.Sprintf(` <rdf:Description rdf:about="" xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#" xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#">
|
||||
<pdfaExtension:schemas>
|
||||
<rdf:Bag>
|
||||
%s
|
||||
</rdf:Bag>
|
||||
</pdfaExtension:schemas>
|
||||
</rdf:Description>
|
||||
`, facturXSchemaLi())
|
||||
}
|
||||
|
||||
// facturXSchemaLi builds the rdf:li describing the fx schema and its four
|
||||
// properties. These are fixed schema definitions, not runtime invoice values.
|
||||
func facturXSchemaLi() string {
|
||||
return fmt.Sprintf(` <rdf:li rdf:parseType="Resource">
|
||||
<pdfaSchema:schema>Factur-X PDFA Extension Schema</pdfaSchema:schema>
|
||||
<pdfaSchema:namespaceURI>%s</pdfaSchema:namespaceURI>
|
||||
<pdfaSchema:prefix>fx</pdfaSchema:prefix>
|
||||
<pdfaSchema:property>
|
||||
<rdf:Seq>
|
||||
<rdf:li rdf:parseType="Resource">
|
||||
<pdfaProperty:name>DocumentFileName</pdfaProperty:name>
|
||||
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
||||
<pdfaProperty:category>external</pdfaProperty:category>
|
||||
<pdfaProperty:description>name of the embedded XML invoice file</pdfaProperty:description>
|
||||
</rdf:li>
|
||||
<rdf:li rdf:parseType="Resource">
|
||||
<pdfaProperty:name>DocumentType</pdfaProperty:name>
|
||||
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
||||
<pdfaProperty:category>external</pdfaProperty:category>
|
||||
<pdfaProperty:description>The type of the embedded Factur-X document</pdfaProperty:description>
|
||||
</rdf:li>
|
||||
<rdf:li rdf:parseType="Resource">
|
||||
<pdfaProperty:name>Version</pdfaProperty:name>
|
||||
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
||||
<pdfaProperty:category>external</pdfaProperty:category>
|
||||
<pdfaProperty:description>The actual version of the Factur-X XML schema</pdfaProperty:description>
|
||||
</rdf:li>
|
||||
<rdf:li rdf:parseType="Resource">
|
||||
<pdfaProperty:name>ConformanceLevel</pdfaProperty:name>
|
||||
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
||||
<pdfaProperty:category>external</pdfaProperty:category>
|
||||
<pdfaProperty:description>The conformance level of the embedded Factur-X data</pdfaProperty:description>
|
||||
</rdf:li>
|
||||
</rdf:Seq>
|
||||
</pdfaSchema:property>
|
||||
</rdf:li>`, facturXNamespaceURI)
|
||||
}
|
||||
|
||||
// xmlEscape escapes a string for safe inclusion in XML character data.
|
||||
func xmlEscape(s string) string {
|
||||
var buf bytes.Buffer
|
||||
_ = xml.EscapeText(&buf, []byte(s))
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// Watermark is not available in this implementation.
|
||||
func (engine *QPdf) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Watermark",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -653,7 +1109,7 @@ func (engine *QPdf) Watermark(ctx context.Context, logger *slog.Logger, inputPat
|
||||
func (engine *QPdf) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Stamp",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -667,7 +1123,7 @@ func (engine *QPdf) Stamp(ctx context.Context, logger *slog.Logger, inputPath st
|
||||
func (engine *QPdf) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Rotate",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
|
||||
58
pkg/modules/qpdf/version_test.go
Normal file
58
pkg/modules/qpdf/version_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package qpdf
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestQPdfDetectVersion(t *testing.T) {
|
||||
t.Run("prefers the build-time version without executing qpdf", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "qpdf"), []byte("qpdf version 11.9.0\n"), 0o600); err != nil {
|
||||
t.Fatalf("write version file: %v", err)
|
||||
}
|
||||
t.Setenv(gotenberg.BuildVersionsDirPathEnvVar, dir)
|
||||
|
||||
// A bogus binPath would error if executed, so a correct result proves
|
||||
// the build-time file is used instead of running qpdf.
|
||||
engine := &QPdf{binPath: "/nonexistent/qpdf"}
|
||||
if got := engine.Debug()["version"]; got != "qpdf version 11.9.0" {
|
||||
t.Errorf("Debug()[version] = %v, want the build-time value", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("falls back to executing qpdf when no build-time version", func(t *testing.T) {
|
||||
t.Setenv(gotenberg.BuildVersionsDirPathEnvVar, "")
|
||||
|
||||
// With no build-time file and a bogus binPath, the exec fallback runs
|
||||
// and records its error rather than a build-time value.
|
||||
engine := &QPdf{binPath: "/nonexistent/qpdf"}
|
||||
if got := engine.Debug()["version"]; got == "qpdf version 11.9.0" {
|
||||
t.Errorf("Debug()[version] = %v, expected the exec fallback", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exposes the version as a span attribute", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "qpdf"), []byte("qpdf version 12.2.0"), 0o600); err != nil {
|
||||
t.Fatalf("write version file: %v", err)
|
||||
}
|
||||
t.Setenv(gotenberg.BuildVersionsDirPathEnvVar, dir)
|
||||
|
||||
// spanAttrs is what gets handed to trace.WithAttributes, so its output
|
||||
// is exactly what a span carries.
|
||||
engine := &QPdf{binPath: "/nonexistent/qpdf"}
|
||||
var got string
|
||||
for _, attr := range engine.spanAttrs() {
|
||||
if attr.Key == "gotenberg.qpdf.version" {
|
||||
got = attr.Value.AsString()
|
||||
}
|
||||
}
|
||||
if got != "qpdf version 12.2.0" {
|
||||
t.Errorf("span attribute gotenberg.qpdf.version = %q, want the build-time value", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
62
pkg/modules/webhook/async_context.go
Normal file
62
pkg/modules/webhook/async_context.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||
)
|
||||
|
||||
// detachAsyncContext detaches ctx from the inbound request lifecycle so the
|
||||
// webhook goroutine survives echo recycling the request, while preserving the
|
||||
// conversion deadline and the caller's trace.
|
||||
//
|
||||
// Echo cancels the request context as soon as the synchronous handler returns
|
||||
// [api.ErrAsyncProcess], which would abort the asynchronous work. Detaching via
|
||||
// [context.WithoutCancel] severs that cancellation while keeping the context
|
||||
// values. [context.WithoutCancel] also drops the deadline, so it is re-layered.
|
||||
//
|
||||
// The server span ends as soon as that handler returns, so its span context is
|
||||
// re-seated as a remote, non-recording parent: the asynchronous worker keeps
|
||||
// the same trace without recording into a span that is about to end. A
|
||||
// worker-root [webhook.Async] span is then opened, linked back to the
|
||||
// originating request span, and stays open for the whole delivery so downstream
|
||||
// conversion and webhook spans have a live parent in the caller's trace.
|
||||
//
|
||||
// The returned cancel function ends the worker span and cleans up both the
|
||||
// detached context and the original working directory.
|
||||
func detachAsyncContext(ctx *api.Context, cancel context.CancelFunc) context.CancelFunc {
|
||||
deadline, hasDeadline := ctx.Deadline()
|
||||
|
||||
serverSpanCtx := trace.SpanContextFromContext(ctx.Context)
|
||||
base := context.WithoutCancel(ctx.Context)
|
||||
if serverSpanCtx.IsValid() {
|
||||
base = trace.ContextWithRemoteSpanContext(base, serverSpanCtx)
|
||||
}
|
||||
|
||||
var detachedCtx context.Context
|
||||
var detachedCancel context.CancelFunc
|
||||
if hasDeadline {
|
||||
detachedCtx, detachedCancel = context.WithDeadline(base, deadline)
|
||||
} else {
|
||||
// Fallback if no deadline was set (rare, as newContext enforces it).
|
||||
detachedCtx, detachedCancel = context.WithCancel(base)
|
||||
}
|
||||
|
||||
startOpts := []trace.SpanStartOption{trace.WithSpanKind(trace.SpanKindInternal)}
|
||||
if serverSpanCtx.IsValid() {
|
||||
startOpts = append(startOpts, trace.WithLinks(trace.Link{SpanContext: serverSpanCtx}))
|
||||
}
|
||||
|
||||
workerCtx, workerSpan := gotenberg.Tracer().Start(detachedCtx, "webhook.Async", startOpts...)
|
||||
ctx.Context = workerCtx
|
||||
|
||||
originalCancel := cancel
|
||||
return func() {
|
||||
workerSpan.End()
|
||||
detachedCancel()
|
||||
originalCancel()
|
||||
}
|
||||
}
|
||||
84
pkg/modules/webhook/async_context_test.go
Normal file
84
pkg/modules/webhook/async_context_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||
)
|
||||
|
||||
func TestDetachAsyncContext_PreservesTraceContext(t *testing.T) {
|
||||
traceID, _ := trace.TraceIDFromHex("0123456789abcdef0123456789abcdef")
|
||||
spanID, _ := trace.SpanIDFromHex("0123456789abcdef")
|
||||
sc := trace.NewSpanContext(trace.SpanContextConfig{
|
||||
TraceID: traceID,
|
||||
SpanID: spanID,
|
||||
TraceFlags: trace.FlagsSampled,
|
||||
Remote: true,
|
||||
})
|
||||
|
||||
reqCtx, reqCancel := context.WithDeadline(
|
||||
trace.ContextWithSpanContext(context.Background(), sc),
|
||||
time.Now().Add(2*time.Hour),
|
||||
)
|
||||
defer reqCancel()
|
||||
|
||||
ctx := &api.Context{Context: reqCtx}
|
||||
cancel := detachAsyncContext(ctx, func() {})
|
||||
defer cancel()
|
||||
|
||||
got := trace.SpanContextFromContext(ctx.Context)
|
||||
if got.TraceID() != sc.TraceID() {
|
||||
t.Errorf("expected the detached context to keep trace id %s, got %s", sc.TraceID(), got.TraceID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetachAsyncContext_PreservesDeadline(t *testing.T) {
|
||||
deadline := time.Now().Add(2 * time.Hour)
|
||||
reqCtx, reqCancel := context.WithDeadline(context.Background(), deadline)
|
||||
defer reqCancel()
|
||||
|
||||
ctx := &api.Context{Context: reqCtx}
|
||||
cancel := detachAsyncContext(ctx, func() {})
|
||||
defer cancel()
|
||||
|
||||
got, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
t.Fatal("expected the detached context to keep a deadline")
|
||||
}
|
||||
if !got.Equal(deadline) {
|
||||
t.Errorf("expected deadline %v, got %v", deadline, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetachAsyncContext_SurvivesRequestCancellation(t *testing.T) {
|
||||
reqCtx, reqCancel := context.WithDeadline(context.Background(), time.Now().Add(2*time.Hour))
|
||||
|
||||
ctx := &api.Context{Context: reqCtx}
|
||||
cancel := detachAsyncContext(ctx, func() {})
|
||||
defer cancel()
|
||||
|
||||
// Cancelling the inbound request must not abort the detached context.
|
||||
reqCancel()
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
t.Errorf("expected the detached context to survive request cancellation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetachAsyncContext_CancelInvokesOriginal(t *testing.T) {
|
||||
reqCtx, reqCancel := context.WithDeadline(context.Background(), time.Now().Add(time.Hour))
|
||||
defer reqCancel()
|
||||
|
||||
called := 0
|
||||
ctx := &api.Context{Context: reqCtx}
|
||||
cancel := detachAsyncContext(ctx, func() { called++ })
|
||||
|
||||
cancel()
|
||||
if called != 1 {
|
||||
t.Errorf("expected the original cancel to be invoked once, got %d", called)
|
||||
}
|
||||
}
|
||||
@@ -224,7 +224,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
||||
startTime: startTime,
|
||||
|
||||
client: &retryablehttp.Client{
|
||||
HTTPClient: gotenberg.NewOutboundHttpClient(w.clientTimeout, w.allowList, w.denyList, ipOpts...),
|
||||
HTTPClient: gotenberg.NewOutboundHttpClient(w.clientTimeout, w.allowList, w.denyList, w.enableEnvironmentProxy, ipOpts...),
|
||||
RetryMax: w.maxRetry,
|
||||
RetryWaitMin: w.retryMinWait,
|
||||
RetryWaitMax: w.retryMaxWait,
|
||||
@@ -314,27 +314,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
// Create a new context derived from Background (detached from Request)
|
||||
// but with the same deadline as the original context.
|
||||
detachedCtx, detachedCancel := context.WithDeadline(context.Background(), deadline)
|
||||
|
||||
// Replace the embedded context in the api.Context struct.
|
||||
// The modules downstream will now use this detached context.
|
||||
ctx.Context = detachedCtx
|
||||
|
||||
// We must wrap the cancel function.
|
||||
// 1. detachedCancel() cleans up our new detached context.
|
||||
// 2. originalCancel() (captured from c.Get("cancel")) cleans up the working directory.
|
||||
originalCancel := cancel
|
||||
cancel = func() {
|
||||
detachedCancel()
|
||||
originalCancel()
|
||||
}
|
||||
} else {
|
||||
// Fallback if no deadline was set (rare, as newContext enforces it).
|
||||
ctx.Context = context.Background()
|
||||
}
|
||||
cancel = detachAsyncContext(ctx, cancel)
|
||||
|
||||
// As a webhook URL has been given, we handle the request in a
|
||||
// goroutine and return immediately.
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -18,19 +18,20 @@ func init() {
|
||||
// Webhook is a module that provides a middleware for uploading output files
|
||||
// to any destinations in an asynchronous fashion.
|
||||
type Webhook struct {
|
||||
enableSyncMode bool
|
||||
allowList []*regexp2.Regexp
|
||||
denyList []*regexp2.Regexp
|
||||
errorAllowList []*regexp2.Regexp
|
||||
errorDenyList []*regexp2.Regexp
|
||||
denyPrivateIPs bool
|
||||
denyPublicIPs bool
|
||||
maxRetry int
|
||||
retryMinWait time.Duration
|
||||
retryMaxWait time.Duration
|
||||
clientTimeout time.Duration
|
||||
asyncCount atomic.Int64
|
||||
disable bool
|
||||
enableSyncMode bool
|
||||
allowList []*regexp2.Regexp
|
||||
denyList []*regexp2.Regexp
|
||||
errorAllowList []*regexp2.Regexp
|
||||
errorDenyList []*regexp2.Regexp
|
||||
denyPrivateIPs bool
|
||||
denyPublicIPs bool
|
||||
enableEnvironmentProxy bool
|
||||
maxRetry int
|
||||
retryMinWait time.Duration
|
||||
retryMaxWait time.Duration
|
||||
clientTimeout time.Duration
|
||||
asyncCount atomic.Int64
|
||||
disable bool
|
||||
}
|
||||
|
||||
// Descriptor returns an [Webhook]'s module descriptor.
|
||||
@@ -44,6 +45,7 @@ func (w *Webhook) Descriptor() gotenberg.ModuleDescriptor {
|
||||
fs.StringSlice("webhook-deny-list", []string{}, "Set the denied URLs for the webhook feature using regular expressions - supports multiple values")
|
||||
fs.Bool("webhook-deny-private-ips", false, "Reject webhook URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted webhook destinations to mitigate SSRF against internal services")
|
||||
fs.Bool("webhook-deny-public-ips", false, "Reject webhook URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent callbacks from leaving a private network")
|
||||
fs.Bool("webhook-enable-environment-proxy", false, "Route webhook callbacks through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials")
|
||||
fs.Int("webhook-max-retry", 4, "Set the maximum number of retries for the webhook feature")
|
||||
|
||||
// Deprecated flags.
|
||||
@@ -78,6 +80,7 @@ func (w *Webhook) Provision(ctx *gotenberg.Context) error {
|
||||
w.errorDenyList = flags.MustDeprecatedRegexpSlice("webhook-error-deny-list", "webhook-deny-list")
|
||||
w.denyPrivateIPs = flags.MustBool("webhook-deny-private-ips")
|
||||
w.denyPublicIPs = flags.MustBool("webhook-deny-public-ips")
|
||||
w.enableEnvironmentProxy = flags.MustBool("webhook-enable-environment-proxy")
|
||||
w.maxRetry = flags.MustInt("webhook-max-retry")
|
||||
w.retryMinWait = flags.MustDuration("webhook-retry-min-wait")
|
||||
w.retryMaxWait = flags.MustDuration("webhook-retry-max-wait")
|
||||
|
||||
@@ -68,9 +68,11 @@ Available tags:
|
||||
- `the "<name>" PDF should have <N> page(s)`
|
||||
- `the "<name>" PDF (should|should NOT) be set to landscape orientation`
|
||||
- `the "<name>" PDF (should|should NOT) have the following content at page <N>:` (docstring)
|
||||
- `the "<name>" PDF (should|should NOT) have content matching "<regexp>" at page <N>`
|
||||
- `the (response|webhook request) PDF(s) should be valid "<standard>" with a tolerance of <N> failed rule(s)` (standards: `PDF/A-1b`, `PDF/A-2b`, `PDF/A-3b`, `PDF/UA-1`, `PDF/UA-2`)
|
||||
- `the (response|webhook request) PDF(s) (should|should NOT) be flatten`
|
||||
- `the (response|webhook request) PDF(s) (should|should NOT) be encrypted`
|
||||
- `the (response|webhook request) PDF(s) (should|should NOT) allow "<action>"` (actions: `printing`, `copying`, `modifying`, `annotating`)
|
||||
- `the (response|webhook request) PDF(s) (should|should NOT) have the "<filename>" file embedded`
|
||||
- `the "<name>" PDF should have <N> image(s)`
|
||||
- `the Gotenberg container (should|should NOT) log the following entries:` (table of log substrings)
|
||||
|
||||
@@ -548,6 +548,25 @@ Feature: /forms/chromium/convert/html
|
||||
Error: Exception 2
|
||||
"""
|
||||
|
||||
Scenario: POST /forms/chromium/convert/html (Document Outline)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||
| files | testdata/page-outline-html/index.html | file |
|
||||
| generateDocumentOutline | true | field |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "application/pdf"
|
||||
Then there should be 1 PDF(s) in the response
|
||||
# generateTaggedPdf is left unset, yet the outline must still be embedded:
|
||||
# Gotenberg enables tagged PDF automatically because Chromium derives the
|
||||
# outline from the structure tree. See issue #1579.
|
||||
Then the "foo.pdf" PDF should have a document outline
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||
| files | testdata/page-outline-html/index.html | file |
|
||||
| Gotenberg-Output-Filename | bar | header |
|
||||
Then the response status code should be 200
|
||||
Then the "bar.pdf" PDF should NOT have a document outline
|
||||
|
||||
Scenario: POST /forms/chromium/convert/html (Bad Request)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||
@@ -735,7 +754,7 @@ Feature: /forms/chromium/convert/html
|
||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||
Then the response body should match string:
|
||||
"""
|
||||
At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
|
||||
The requested split mode is not supported, or no PDF engine could process it. Valid modes: 'intervals', 'pages'.
|
||||
"""
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||
| files | testdata/page-1-html/index.html | file |
|
||||
@@ -744,7 +763,7 @@ Feature: /forms/chromium/convert/html
|
||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||
Then the response body should match string:
|
||||
"""
|
||||
At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
|
||||
The requested PDF format is not supported, or no PDF engine could apply it. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.
|
||||
"""
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||
| files | testdata/page-1-html/index.html | file |
|
||||
|
||||
@@ -652,7 +652,7 @@ Feature: /forms/chromium/convert/markdown
|
||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||
Then the response body should match string:
|
||||
"""
|
||||
At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
|
||||
The requested split mode is not supported, or no PDF engine could process it. Valid modes: 'intervals', 'pages'.
|
||||
"""
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
|
||||
| files | testdata/page-1-markdown/index.html | file |
|
||||
@@ -662,7 +662,7 @@ Feature: /forms/chromium/convert/markdown
|
||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||
Then the response body should match string:
|
||||
"""
|
||||
At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
|
||||
The requested PDF format is not supported, or no PDF engine could apply it. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.
|
||||
"""
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
|
||||
| files | testdata/page-1-markdown/index.html | file |
|
||||
|
||||
@@ -815,7 +815,7 @@ Feature: /forms/chromium/convert/url
|
||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||
Then the response body should match string:
|
||||
"""
|
||||
At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
|
||||
The requested split mode is not supported, or no PDF engine could process it. Valid modes: 'intervals', 'pages'.
|
||||
"""
|
||||
Given I have a static server
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||
@@ -825,7 +825,7 @@ Feature: /forms/chromium/convert/url
|
||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||
Then the response body should match string:
|
||||
"""
|
||||
At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
|
||||
The requested PDF format is not supported, or no PDF engine could apply it. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.
|
||||
"""
|
||||
Given I have a static server
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||
|
||||
@@ -18,6 +18,25 @@ Feature: /forms/libreoffice/convert
|
||||
Page 1
|
||||
"""
|
||||
|
||||
# A CSV becomes a single Calc sheet named after the input file, and Calc's
|
||||
# default page style prints that sheet name as a centered header. Uploads are
|
||||
# stored under a UUID-based filename, so the UUID must not leak into the PDF.
|
||||
# See https://github.com/gotenberg/gotenberg/issues/1568.
|
||||
Scenario: POST /forms/libreoffice/convert (CSV Without Sheet Name Header)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||
| files | testdata/sheet.csv | file |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "application/pdf"
|
||||
Then there should be 1 PDF(s) in the response
|
||||
Then the "foo.pdf" PDF should have 1 page(s)
|
||||
Then the "foo.pdf" PDF should have the following content at page 1:
|
||||
"""
|
||||
Alice
|
||||
"""
|
||||
Then the "foo.pdf" PDF should NOT have content matching "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" at page 1
|
||||
|
||||
Scenario: POST /forms/libreoffice/convert (Many Documents)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||
@@ -294,7 +313,7 @@ Feature: /forms/libreoffice/convert
|
||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||
Then the response body should match string:
|
||||
"""
|
||||
At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
|
||||
The requested split mode is not supported, or no PDF engine could process it. Valid modes: 'intervals', 'pages'.
|
||||
"""
|
||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||
| files | testdata/pages_3.docx | file |
|
||||
@@ -303,7 +322,7 @@ Feature: /forms/libreoffice/convert
|
||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||
Then the response body should match string:
|
||||
"""
|
||||
A PDF format in '{PdfA:foo PdfUa:false}' is not supported
|
||||
The PDF format 'foo' is not supported. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.
|
||||
"""
|
||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||
| files | testdata/page_1.docx | file |
|
||||
@@ -695,6 +714,75 @@ Feature: /forms/libreoffice/convert
|
||||
Then the response PDF(s) should have the "embed_1.xml" file embedded
|
||||
Then the response PDF(s) should have the "embed_2.xml" file embedded
|
||||
|
||||
# A Factur-X request supplies the invoice XML via facturxXml plus the
|
||||
# facturxConformanceLevel; Gotenberg owns the PDF/A-3, the Alternative
|
||||
# relationship, and the canonical factur-x.xml name. No explicit pdfa here
|
||||
# exercises the automatic PDF/A-3b default for a source document.
|
||||
@convert
|
||||
@factur-x
|
||||
Scenario: POST /forms/libreoffice/convert (Factur-X / ZUGFeRD)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||
| files | testdata/page_1.docx | file |
|
||||
| facturxXml | testdata/embed_1.xml | file |
|
||||
| facturxConformanceLevel | EN 16931 | field |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "application/pdf"
|
||||
Then there should be 1 PDF(s) in the response
|
||||
Then the response PDF(s) should be valid "PDF/A-3b" with a tolerance of 0 failed rule(s)
|
||||
Then the response PDF(s) should have the "factur-x.xml" file embedded with relationship "Alternative"
|
||||
Then the response PDF(s) should declare Factur-X XMP with conformance level "EN 16931"
|
||||
|
||||
# The base PDF is already PDF/A-3b: detection keeps it as-is, no reconversion.
|
||||
@factur-x
|
||||
Scenario: POST /forms/pdfengines/factur-x (Standalone, already PDF/A-3)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||
| files | testdata/page_1.docx | file |
|
||||
| pdfa | PDF/A-3b | field |
|
||||
| Gotenberg-Output-Filename | base | header |
|
||||
Then the response status code should be 200
|
||||
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/factur-x" endpoint with the following form data and header(s):
|
||||
| files | teststore/base.pdf | file |
|
||||
| facturxXml | testdata/embed_1.xml | file |
|
||||
| facturxConformanceLevel | BASIC | field |
|
||||
| facturxDocumentType | ORDER | field |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "application/pdf"
|
||||
Then there should be 1 PDF(s) in the response
|
||||
Then the response PDF(s) should be valid "PDF/A-3b" with a tolerance of 0 failed rule(s)
|
||||
Then the response PDF(s) should have the "factur-x.xml" file embedded with relationship "Alternative"
|
||||
Then the response PDF(s) should declare Factur-X XMP with conformance level "BASIC"
|
||||
|
||||
# The base PDF is not PDF/A: detection converts it to PDF/A-3b automatically.
|
||||
@factur-x
|
||||
Scenario: POST /forms/pdfengines/factur-x (Standalone, converts non-PDF/A input)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||
| files | testdata/page_1.docx | file |
|
||||
| Gotenberg-Output-Filename | plain | header |
|
||||
Then the response status code should be 200
|
||||
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/factur-x" endpoint with the following form data and header(s):
|
||||
| files | teststore/plain.pdf | file |
|
||||
| facturxXml | testdata/embed_1.xml | file |
|
||||
| facturxConformanceLevel | EN 16931 | field |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
Then the response PDF(s) should be valid "PDF/A-3b" with a tolerance of 0 failed rule(s)
|
||||
Then the response PDF(s) should have the "factur-x.xml" file embedded with relationship "Alternative"
|
||||
Then the response PDF(s) should declare Factur-X XMP with conformance level "EN 16931"
|
||||
|
||||
# facturxConformanceLevel without facturxXml is a half-specified request.
|
||||
@factur-x
|
||||
Scenario: POST /forms/pdfengines/factur-x (Bad Request)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/factur-x" endpoint with the following form data and header(s):
|
||||
| files | testdata/page_1.pdf | file |
|
||||
| facturxConformanceLevel | EN 16931 | field |
|
||||
Then the response status code should be 400
|
||||
|
||||
# FIXME: once decrypt is done, add encrypt and check after the content of the PDF.
|
||||
@convert
|
||||
@metadata
|
||||
@@ -851,3 +939,28 @@ Feature: /forms/libreoffice/convert
|
||||
Then the response header "Content-Type" should be "application/pdf"
|
||||
Then there should be 1 PDF(s) in the response
|
||||
Then the "foo.pdf" PDF should have 1 page(s)
|
||||
|
||||
# An embedded image is stored inside the document, not linked, so blocking
|
||||
# untrusted linked content leaves it untouched. Guards against over-blocking.
|
||||
@libreoffice-linked-content
|
||||
Scenario: POST /forms/libreoffice/convert (Embedded Image Survives)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||
| files | testdata/libreoffice-embedded-image.fodt | file |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
Then there should be 1 PDF(s) in the response
|
||||
Then the "foo.pdf" PDF should have 1 image(s)
|
||||
|
||||
# An uploaded document always loads from an untrusted location, so soffice
|
||||
# refuses to resolve any content it links (absolute file:// path or external
|
||||
# URL). Closes the SSRF and local-file-read vector.
|
||||
@libreoffice-linked-content
|
||||
Scenario: POST /forms/libreoffice/convert (Linked External Resource Blocked)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||
| files | testdata/libreoffice-linked-external.fodt | file |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
Then there should be 1 PDF(s) in the response
|
||||
Then the "foo.pdf" PDF should have 0 image(s)
|
||||
|
||||
@@ -93,7 +93,7 @@ Feature: /forms/pdfengines/convert
|
||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||
Then the response body should match string:
|
||||
"""
|
||||
At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
|
||||
The requested PDF format is not supported, or no PDF engine could apply it. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.
|
||||
"""
|
||||
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s):
|
||||
| files | testdata/page_1.pdf | file |
|
||||
|
||||
@@ -113,9 +113,31 @@ Feature: /forms/pdfengines/encrypt
|
||||
Then the response status code should be 400
|
||||
Then the response body should match string:
|
||||
"""
|
||||
Invalid form data: form field 'userPassword' is required
|
||||
Invalid form data: a 'userPassword' or 'ownerPassword' is required
|
||||
"""
|
||||
|
||||
# https://github.com/gotenberg/gotenberg/discussions/1571
|
||||
# Owner-only: opens without a password but restricts printing.
|
||||
Scenario: POST /forms/pdfengines/encrypt (Owner-only, restrict printing)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
|
||||
| files | testdata/page_1.pdf | file |
|
||||
| ownerPassword | owner-secret | field |
|
||||
| allowPrinting | false | field |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "application/pdf"
|
||||
Then there should be 1 PDF(s) in the response
|
||||
Then the response PDF(s) should NOT be encrypted
|
||||
Then the response PDF(s) should NOT allow "printing"
|
||||
|
||||
# Permission restrictions need a password to anchor them.
|
||||
Scenario: POST /forms/pdfengines/encrypt (Permissions without password)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s):
|
||||
| files | testdata/page_1.pdf | file |
|
||||
| allowPrinting | false | field |
|
||||
Then the response status code should be 400
|
||||
|
||||
Scenario: POST /forms/pdfengines/encrypt (Routes Disabled)
|
||||
Given I have a Gotenberg container with the following environment variable(s):
|
||||
| PDFENGINES_DISABLE_ROUTES | true |
|
||||
|
||||
@@ -108,7 +108,7 @@ Feature: /forms/pdfengines/merge
|
||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||
Then the response body should match string:
|
||||
"""
|
||||
At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
|
||||
The requested PDF format is not supported, or no PDF engine could apply it. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.
|
||||
"""
|
||||
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
|
||||
| files | testdata/page_1.pdf | file |
|
||||
|
||||
@@ -120,7 +120,7 @@ Feature: /forms/pdfengines/metadata/{write|read}
|
||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||
Then the response body should contain string:
|
||||
"""
|
||||
At least one PDF engine cannot process the requested metadata
|
||||
The requested metadata could not be written
|
||||
"""
|
||||
|
||||
Scenario: POST /forms/pdfengines/metadata/write (Reject Group-Prefixed Dangerous Tag)
|
||||
|
||||
@@ -283,7 +283,7 @@ Feature: /forms/pdfengines/split
|
||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||
Then the response body should match string:
|
||||
"""
|
||||
At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
|
||||
The requested split mode is not supported, or no PDF engine could process it. Valid modes: 'intervals', 'pages'.
|
||||
"""
|
||||
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
|
||||
| files | testdata/pages_3.pdf | file |
|
||||
@@ -294,7 +294,7 @@ Feature: /forms/pdfengines/split
|
||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||
Then the response body should match string:
|
||||
"""
|
||||
At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
|
||||
The requested PDF format is not supported, or no PDF engine could apply it. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.
|
||||
"""
|
||||
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
|
||||
| files | testdata/pages_3.pdf | file |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user