mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 00:22:14 +01:00
Compare commits
87 Commits
v8.32.0
...
8b559eb699
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b559eb699 | ||
|
|
50e8e44bc1 | ||
|
|
b243283c9b | ||
|
|
a0d5e93ced | ||
|
|
7568fc379b | ||
|
|
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 | ||
|
|
190cad0ee2 | ||
|
|
1d0c9acb2a | ||
|
|
08181f8550 | ||
|
|
9ea0e82525 | ||
|
|
7967035981 | ||
|
|
3d891edee4 | ||
|
|
320ad62e7b | ||
|
|
27e70fde46 | ||
|
|
2a9bf6bf11 | ||
|
|
7f9c3e171c | ||
|
|
13c5b99962 | ||
|
|
6671b5e5d3 | ||
|
|
f9a01c9fb3 | ||
|
|
26d373854a | ||
|
|
93d0103585 | ||
|
|
c1cdcbdaab |
@@ -50,9 +50,19 @@ body:multipart-form {
|
|||||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||||
~userPassword:
|
~userPassword:
|
||||||
~ownerPassword:
|
~ownerPassword:
|
||||||
|
~allowPrinting: false
|
||||||
|
~allowCopying: false
|
||||||
|
~allowModifying: false
|
||||||
|
~allowAnnotating: false
|
||||||
|
~allowFillingForms: false
|
||||||
|
~allowAssembling: false
|
||||||
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||||
~embeds: @file(../test/integration/testdata/embed_2.xml)
|
~embeds: @file(../test/integration/testdata/embed_2.xml)
|
||||||
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
||||||
|
~facturxXml: @file(../test/integration/testdata/embed_1.xml)
|
||||||
|
~facturxConformanceLevel: EN 16931
|
||||||
|
~facturxDocumentType: INVOICE
|
||||||
|
~facturxVersion: 1.0
|
||||||
~watermarkSource: text
|
~watermarkSource: text
|
||||||
~watermarkExpression: CONFIDENTIAL
|
~watermarkExpression: CONFIDENTIAL
|
||||||
~watermarkPages:
|
~watermarkPages:
|
||||||
|
|||||||
@@ -51,9 +51,19 @@ body:multipart-form {
|
|||||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||||
~userPassword:
|
~userPassword:
|
||||||
~ownerPassword:
|
~ownerPassword:
|
||||||
|
~allowPrinting: false
|
||||||
|
~allowCopying: false
|
||||||
|
~allowModifying: false
|
||||||
|
~allowAnnotating: false
|
||||||
|
~allowFillingForms: false
|
||||||
|
~allowAssembling: false
|
||||||
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||||
~embeds: @file(../test/integration/testdata/embed_2.xml)
|
~embeds: @file(../test/integration/testdata/embed_2.xml)
|
||||||
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
||||||
|
~facturxXml: @file(../../test/integration/testdata/embed_1.xml)
|
||||||
|
~facturxConformanceLevel: EN 16931
|
||||||
|
~facturxDocumentType: INVOICE
|
||||||
|
~facturxVersion: 1.0
|
||||||
~watermarkSource: text
|
~watermarkSource: text
|
||||||
~watermarkExpression: CONFIDENTIAL
|
~watermarkExpression: CONFIDENTIAL
|
||||||
~watermarkPages:
|
~watermarkPages:
|
||||||
|
|||||||
@@ -50,9 +50,19 @@ body:multipart-form {
|
|||||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||||
~userPassword:
|
~userPassword:
|
||||||
~ownerPassword:
|
~ownerPassword:
|
||||||
|
~allowPrinting: false
|
||||||
|
~allowCopying: false
|
||||||
|
~allowModifying: false
|
||||||
|
~allowAnnotating: false
|
||||||
|
~allowFillingForms: false
|
||||||
|
~allowAssembling: false
|
||||||
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||||
~embeds: @file(../test/integration/testdata/embed_2.xml)
|
~embeds: @file(../test/integration/testdata/embed_2.xml)
|
||||||
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
||||||
|
~facturxXml: @file(../test/integration/testdata/embed_1.xml)
|
||||||
|
~facturxConformanceLevel: EN 16931
|
||||||
|
~facturxDocumentType: INVOICE
|
||||||
|
~facturxVersion: 1.0
|
||||||
~watermarkSource: text
|
~watermarkSource: text
|
||||||
~watermarkExpression: CONFIDENTIAL
|
~watermarkExpression: CONFIDENTIAL
|
||||||
~watermarkPages:
|
~watermarkPages:
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ body:multipart-form {
|
|||||||
~emulatedMediaType: screen
|
~emulatedMediaType: screen
|
||||||
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
|
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
|
||||||
~omitBackground: false
|
~omitBackground: false
|
||||||
|
~deviceScaleFactor: 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
headers {
|
headers {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ body:multipart-form {
|
|||||||
~emulatedMediaType: screen
|
~emulatedMediaType: screen
|
||||||
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
|
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
|
||||||
~omitBackground: false
|
~omitBackground: false
|
||||||
|
~deviceScaleFactor: 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
headers {
|
headers {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ body:multipart-form {
|
|||||||
~emulatedMediaType: screen
|
~emulatedMediaType: screen
|
||||||
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
|
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
|
||||||
~omitBackground: false
|
~omitBackground: false
|
||||||
|
~deviceScaleFactor: 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
headers {
|
headers {
|
||||||
|
|||||||
@@ -67,9 +67,19 @@ body:multipart-form {
|
|||||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||||
~userPassword:
|
~userPassword:
|
||||||
~ownerPassword:
|
~ownerPassword:
|
||||||
|
~allowPrinting: false
|
||||||
|
~allowCopying: false
|
||||||
|
~allowModifying: false
|
||||||
|
~allowAnnotating: false
|
||||||
|
~allowFillingForms: false
|
||||||
|
~allowAssembling: false
|
||||||
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||||
~embeds: @file(../test/integration/testdata/embed_2.xml)
|
~embeds: @file(../test/integration/testdata/embed_2.xml)
|
||||||
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
||||||
|
~facturxXml: @file(../test/integration/testdata/embed_1.xml)
|
||||||
|
~facturxConformanceLevel: EN 16931
|
||||||
|
~facturxDocumentType: INVOICE
|
||||||
|
~facturxVersion: 1.0
|
||||||
~watermarkSource: text
|
~watermarkSource: text
|
||||||
~watermarkExpression: CONFIDENTIAL
|
~watermarkExpression: CONFIDENTIAL
|
||||||
~watermarkPages:
|
~watermarkPages:
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ body:multipart-form {
|
|||||||
embeds: @file(../test/integration/testdata/embed_1.xml)
|
embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||||
embeds: @file(../test/integration/testdata/embed_2.xml)
|
embeds: @file(../test/integration/testdata/embed_2.xml)
|
||||||
embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
||||||
|
~facturxXml: @file(../test/integration/testdata/embed_1.xml)
|
||||||
|
~facturxConformanceLevel: EN 16931
|
||||||
|
~facturxDocumentType: INVOICE
|
||||||
|
~facturxVersion: 1.0
|
||||||
~downloadFrom: [{"url":"https://example.com/attachment.xml","embedded":true}]
|
~downloadFrom: [{"url":"https://example.com/attachment.xml","embedded":true}]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ body:multipart-form {
|
|||||||
files: @file(../../test/integration/testdata/page_1.pdf)
|
files: @file(../../test/integration/testdata/page_1.pdf)
|
||||||
userPassword: secret123
|
userPassword: secret123
|
||||||
~ownerPassword: owner456
|
~ownerPassword: owner456
|
||||||
|
~allowPrinting: false
|
||||||
|
~allowCopying: false
|
||||||
|
~allowModifying: false
|
||||||
|
~allowAnnotating: false
|
||||||
|
~allowFillingForms: false
|
||||||
|
~allowAssembling: false
|
||||||
}
|
}
|
||||||
|
|
||||||
headers {
|
headers {
|
||||||
|
|||||||
29
.bruno/PDF Engines/Factur-X/Inject Factur-X XMP.bru
Normal file
29
.bruno/PDF Engines/Factur-X/Inject Factur-X XMP.bru
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
meta {
|
||||||
|
name: Inject Factur-X XMP
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{baseUrl}}/forms/pdfengines/factur-x
|
||||||
|
body: multipartForm
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
body:multipart-form {
|
||||||
|
files: @file(../../test/integration/testdata/page_1.pdf)
|
||||||
|
facturxXml: @file(../../test/integration/testdata/embed_1.xml)
|
||||||
|
facturxConformanceLevel: EN 16931
|
||||||
|
~facturxDocumentType: INVOICE
|
||||||
|
~facturxVersion: 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
headers {
|
||||||
|
~Gotenberg-Output-Filename: factur-x
|
||||||
|
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
|
||||||
|
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
|
||||||
|
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
|
||||||
|
~Gotenberg-Webhook-Method: POST
|
||||||
|
~Gotenberg-Webhook-Error-Method: POST
|
||||||
|
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
|
||||||
|
}
|
||||||
@@ -21,6 +21,12 @@ body:multipart-form {
|
|||||||
~bookmarks: [{"title":"Page 1","page":1},{"title":"Page 2","page":2}]
|
~bookmarks: [{"title":"Page 1","page":1},{"title":"Page 2","page":2}]
|
||||||
~userPassword:
|
~userPassword:
|
||||||
~ownerPassword:
|
~ownerPassword:
|
||||||
|
~allowPrinting: false
|
||||||
|
~allowCopying: false
|
||||||
|
~allowModifying: false
|
||||||
|
~allowAnnotating: false
|
||||||
|
~allowFillingForms: false
|
||||||
|
~allowAssembling: false
|
||||||
~watermarkSource: text
|
~watermarkSource: text
|
||||||
~watermarkExpression: CONFIDENTIAL
|
~watermarkExpression: CONFIDENTIAL
|
||||||
~watermarkPages:
|
~watermarkPages:
|
||||||
@@ -31,6 +37,10 @@ body:multipart-form {
|
|||||||
~stampOptions: {"scale":"0.5 abs","rot":"45"}
|
~stampOptions: {"scale":"0.5 abs","rot":"45"}
|
||||||
~rotateAngle: 90
|
~rotateAngle: 90
|
||||||
~rotatePages:
|
~rotatePages:
|
||||||
|
~facturxXml: @file(../../test/integration/testdata/embed_1.xml)
|
||||||
|
~facturxConformanceLevel: EN 16931
|
||||||
|
~facturxDocumentType: INVOICE
|
||||||
|
~facturxVersion: 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
headers {
|
headers {
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ body:multipart-form {
|
|||||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||||
~userPassword:
|
~userPassword:
|
||||||
~ownerPassword:
|
~ownerPassword:
|
||||||
|
~allowPrinting: false
|
||||||
|
~allowCopying: false
|
||||||
|
~allowModifying: false
|
||||||
|
~allowAnnotating: false
|
||||||
|
~allowFillingForms: false
|
||||||
|
~allowAssembling: false
|
||||||
~watermarkSource: text
|
~watermarkSource: text
|
||||||
~watermarkExpression: CONFIDENTIAL
|
~watermarkExpression: CONFIDENTIAL
|
||||||
~watermarkPages:
|
~watermarkPages:
|
||||||
@@ -31,6 +37,10 @@ body:multipart-form {
|
|||||||
~stampOptions: {"scale":"0.5 abs","rot":"45"}
|
~stampOptions: {"scale":"0.5 abs","rot":"45"}
|
||||||
~rotateAngle: 90
|
~rotateAngle: 90
|
||||||
~rotatePages:
|
~rotatePages:
|
||||||
|
~facturxXml: @file(../../test/integration/testdata/embed_1.xml)
|
||||||
|
~facturxConformanceLevel: EN 16931
|
||||||
|
~facturxDocumentType: INVOICE
|
||||||
|
~facturxVersion: 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
headers {
|
headers {
|
||||||
|
|||||||
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
@@ -12,6 +12,12 @@ updates:
|
|||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
ignore:
|
||||||
|
# Held at v0.14.2: v0.15.x breaks the headless print-mode paint pipeline
|
||||||
|
# (rAF / ResizeObserver / IntersectionObserver stop firing, blank charts).
|
||||||
|
# See https://github.com/gotenberg/gotenberg/issues/1535.
|
||||||
|
- dependency-name: "github.com/chromedp/chromedp"
|
||||||
|
- dependency-name: "github.com/chromedp/cdproto"
|
||||||
- package-ecosystem: "npm"
|
- package-ecosystem: "npm"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
|
|||||||
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 }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: build_push
|
id: build_push
|
||||||
@@ -50,7 +50,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: build_push
|
id: build_push
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: build_push
|
id: build_push
|
||||||
@@ -104,7 +104,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: build_push
|
id: build_push
|
||||||
@@ -131,7 +131,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: build_push
|
id: build_push
|
||||||
@@ -154,7 +154,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Merge
|
- name: Merge
|
||||||
uses: ./.github/actions/merge
|
uses: ./.github/actions/merge
|
||||||
|
|||||||
36
.github/workflows/continuous-integration.yml
vendored
36
.github/workflows/continuous-integration.yml
vendored
@@ -21,10 +21,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
@@ -38,10 +38,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version-file: .node-version
|
node-version-file: .node-version
|
||||||
|
|
||||||
@@ -59,10 +59,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build, test and push
|
- name: Build, test and push
|
||||||
id: build_test_push
|
id: build_test_push
|
||||||
@@ -117,7 +117,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build, test and push
|
- name: Build, test and push
|
||||||
id: build_test_push
|
id: build_test_push
|
||||||
@@ -147,7 +147,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build, test and push
|
- name: Build, test and push
|
||||||
id: build_test_push
|
id: build_test_push
|
||||||
@@ -177,7 +177,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build, test and push
|
- name: Build, test and push
|
||||||
id: build_test_push
|
id: build_test_push
|
||||||
@@ -207,7 +207,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build, test and push
|
- name: Build, test and push
|
||||||
id: build_test_push
|
id: build_test_push
|
||||||
@@ -253,7 +253,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Merge
|
- name: Merge
|
||||||
uses: ./.github/actions/merge
|
uses: ./.github/actions/merge
|
||||||
@@ -322,7 +322,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build, test and push
|
- name: Build, test and push
|
||||||
id: build_test_push
|
id: build_test_push
|
||||||
@@ -351,7 +351,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build, test and push
|
- name: Build, test and push
|
||||||
id: build_test_push
|
id: build_test_push
|
||||||
@@ -380,7 +380,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build, test and push
|
- name: Build, test and push
|
||||||
id: build_test_push
|
id: build_test_push
|
||||||
@@ -409,7 +409,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build, test and push
|
- name: Build, test and push
|
||||||
id: build_test_push
|
id: build_test_push
|
||||||
@@ -438,7 +438,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build, test and push
|
- name: Build, test and push
|
||||||
id: build_test_push
|
id: build_test_push
|
||||||
@@ -460,7 +460,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Merge
|
- name: Merge
|
||||||
uses: ./.github/actions/merge
|
uses: ./.github/actions/merge
|
||||||
|
|||||||
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
|
continue-on-error: true
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Cleanup
|
- name: Cleanup
|
||||||
uses: ./.github/actions/clean
|
uses: ./.github/actions/clean
|
||||||
|
|||||||
@@ -85,6 +85,15 @@ If a change violates backward compatibility, flag it as a breaking change in the
|
|||||||
- No panics in production code paths.
|
- No panics in production code paths.
|
||||||
- Validate input defensively.
|
- Validate input defensively.
|
||||||
|
|
||||||
|
### Error messages
|
||||||
|
|
||||||
|
Client- and operator-facing error messages state what failed, why when non-obvious, and how to fix it when a fix exists. Internal errors (the wrapped `fmt.Errorf` chains that only reach logs) are exempt; keep them precise and technical.
|
||||||
|
|
||||||
|
- Client (HTTP response body): name the offending form field and its valid values. Never return a bare `http.StatusText()`.
|
||||||
|
- Operator (startup, `Provision`, `Validate`): name the environment variable or flag to set, plus the path or value checked.
|
||||||
|
- Security and filtering errors stay generic for clients. Don't reveal allow/deny lists or private-IP policy. Log the specific reason for operators.
|
||||||
|
- No hedging ("while others may have failed"). No raw `os.Stat` or exec output in the human-facing remedy.
|
||||||
|
|
||||||
### Logging
|
### Logging
|
||||||
|
|
||||||
Use `gotenberg.Logger(mod)` to get the module's slog logger during `Provision()`. All log calls must be context-aware: `logger.DebugContext(ctx, msg)`, `logger.InfoContext(ctx, msg)`, `logger.ErrorContext(ctx, msg)`. This propagates trace/span IDs into structured logs when OpenTelemetry is active.
|
Use `gotenberg.Logger(mod)` to get the module's slog logger during `Provision()`. All log calls must be context-aware: `logger.DebugContext(ctx, msg)`, `logger.InfoContext(ctx, msg)`, `logger.ErrorContext(ctx, msg)`. This propagates trace/span IDs into structured logs when OpenTelemetry is active.
|
||||||
|
|||||||
17
Makefile
17
Makefile
@@ -18,6 +18,8 @@ GOTENBERG_BUILD_DEBUG_DATA=true
|
|||||||
API_PORT=3000
|
API_PORT=3000
|
||||||
API_PORT_FROM_ENV=
|
API_PORT_FROM_ENV=
|
||||||
API_BIND_IP=
|
API_BIND_IP=
|
||||||
|
API_TLS_CERT_FILE=
|
||||||
|
API_TLS_KEY_FILE=
|
||||||
API_START_TIMEOUT=30s
|
API_START_TIMEOUT=30s
|
||||||
API_TIMEOUT=30s
|
API_TIMEOUT=30s
|
||||||
API_BODY_LIMIT=
|
API_BODY_LIMIT=
|
||||||
@@ -28,6 +30,9 @@ GOTENBERG_API_BASIC_AUTH_USERNAME=
|
|||||||
GOTENBERG_API_BASIC_AUTH_PASSWORD=
|
GOTENBERG_API_BASIC_AUTH_PASSWORD=
|
||||||
API_DOWNLOAD_FROM_ALLOW_LIST=
|
API_DOWNLOAD_FROM_ALLOW_LIST=
|
||||||
API_DOWNLOAD_FROM_DENY_LIST=^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_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_DOWNLOAD_FROM_MAX_RETRY=4
|
||||||
API_DISABLE_DOWNLOAD_FROM=false
|
API_DISABLE_DOWNLOAD_FROM=false
|
||||||
API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=true
|
API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=true
|
||||||
@@ -47,8 +52,11 @@ CHROMIUM_DISABLE_WEB_SECURITY=false
|
|||||||
CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false
|
CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false
|
||||||
CHROMIUM_HOST_RESOLVER_RULES=
|
CHROMIUM_HOST_RESOLVER_RULES=
|
||||||
CHROMIUM_PROXY_SERVER=
|
CHROMIUM_PROXY_SERVER=
|
||||||
|
CHROMIUM_ENABLE_ENVIRONMENT_PROXY=false
|
||||||
CHROMIUM_ALLOW_LIST=
|
CHROMIUM_ALLOW_LIST=
|
||||||
CHROMIUM_DENY_LIST=^file:(?!//\/tmp/).*
|
CHROMIUM_DENY_LIST=^file:(?!//\/tmp/).*
|
||||||
|
CHROMIUM_DENY_PRIVATE_IPS=false
|
||||||
|
CHROMIUM_DENY_PUBLIC_IPS=false
|
||||||
CHROMIUM_CLEAR_CACHE=false
|
CHROMIUM_CLEAR_CACHE=false
|
||||||
CHROMIUM_CLEAR_COOKIES=false
|
CHROMIUM_CLEAR_COOKIES=false
|
||||||
CHROMIUM_DISABLE_JAVASCRIPT=false
|
CHROMIUM_DISABLE_JAVASCRIPT=false
|
||||||
@@ -60,11 +68,15 @@ LIBREOFFICE_AUTO_START=false
|
|||||||
LIBREOFFICE_START_TIMEOUT=20s
|
LIBREOFFICE_START_TIMEOUT=20s
|
||||||
LIBREOFFICE_ALLOW_LIST=
|
LIBREOFFICE_ALLOW_LIST=
|
||||||
LIBREOFFICE_DENY_LIST=
|
LIBREOFFICE_DENY_LIST=
|
||||||
|
LIBREOFFICE_DENY_PRIVATE_IPS=false
|
||||||
|
LIBREOFFICE_DENY_PUBLIC_IPS=false
|
||||||
|
LIBREOFFICE_ENABLE_ENVIRONMENT_PROXY=false
|
||||||
LIBREOFFICE_DISABLE_ROUTES=false
|
LIBREOFFICE_DISABLE_ROUTES=false
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
LOG_FIELDS_PREFIX=
|
LOG_FIELDS_PREFIX=
|
||||||
LOG_STD_FORMAT=auto
|
LOG_STD_FORMAT=auto
|
||||||
LOG_STD_ENABLE_GCP_FIELDS=false
|
LOG_STD_ENABLE_GCP_FIELDS=false
|
||||||
|
LOG_STD_LEVEL_CASE=lower
|
||||||
PDFENGINES_DISABLE_ROUTES=false
|
PDFENGINES_DISABLE_ROUTES=false
|
||||||
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
|
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
|
||||||
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
|
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
|
||||||
@@ -80,6 +92,7 @@ PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
|
|||||||
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
|
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
|
||||||
PDFENGINES_EMBED_ENGINES=qpdf,pdfcpu
|
PDFENGINES_EMBED_ENGINES=qpdf,pdfcpu
|
||||||
PDFENGINES_EMBED_METADATA_ENGINES=qpdf
|
PDFENGINES_EMBED_METADATA_ENGINES=qpdf
|
||||||
|
PDFENGINES_FACTUR_X_ENGINES=qpdf
|
||||||
PROMETHEUS_NAMESPACE=gotenberg
|
PROMETHEUS_NAMESPACE=gotenberg
|
||||||
PROMETHEUS_COLLECT_INTERVAL=1s
|
PROMETHEUS_COLLECT_INTERVAL=1s
|
||||||
PROMETHEUS_DISABLE_ROUTE_TELEMETRY=true
|
PROMETHEUS_DISABLE_ROUTE_TELEMETRY=true
|
||||||
@@ -95,6 +108,9 @@ OTEL_EXPORTER_OTLP_INSECURE=true
|
|||||||
WEBHOOK_ENABLE_SYNC_MODE=false
|
WEBHOOK_ENABLE_SYNC_MODE=false
|
||||||
WEBHOOK_ALLOW_LIST=
|
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_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_MAX_RETRY=4
|
||||||
WEBHOOK_RETRY_MIN_WAIT=1s
|
WEBHOOK_RETRY_MIN_WAIT=1s
|
||||||
WEBHOOK_RETRY_MAX_WAIT=30s
|
WEBHOOK_RETRY_MAX_WAIT=30s
|
||||||
@@ -156,6 +172,7 @@ NO_CONCURRENCY=false
|
|||||||
# stamp
|
# stamp
|
||||||
# pdfengines-rotate
|
# pdfengines-rotate
|
||||||
# rotate
|
# rotate
|
||||||
|
# factur-x
|
||||||
# pdfengines-bookmarks
|
# pdfengines-bookmarks
|
||||||
# bookmarks
|
# bookmarks
|
||||||
# prometheus-metrics
|
# prometheus-metrics
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://user-images.githubusercontent.com/8983173/130322857-185831e2-f041-46eb-a17f-0a69d066c4e5.png" alt="Gotenberg Logo" width="150" height="150" />
|
<img src="https://raw.githubusercontent.com/gotenberg/art/master/logo.png" alt="Gotenberg Logo" width="150" height="150" />
|
||||||
<h3 align="center">Gotenberg</h3>
|
<h3 align="center">Gotenberg</h3>
|
||||||
<p align="center">A Docker-based API for converting documents to PDF</p>
|
<p align="center">A Docker-based API for converting documents to PDF</p>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -64,6 +64,7 @@ If Gotenberg powers your workflow or your business, consider [**becoming a spons
|
|||||||
- [TheCodingMachine](https://thecodingmachine.com/)
|
- [TheCodingMachine](https://thecodingmachine.com/)
|
||||||
- [pdfme](https://pdfme.com/)
|
- [pdfme](https://pdfme.com/)
|
||||||
- [PDFBolt](https://pdfbolt.com)
|
- [PDFBolt](https://pdfbolt.com)
|
||||||
|
- [FileToPDF.dev](https://filetopdf.dev)
|
||||||
|
|
||||||
**Powered By**
|
**Powered By**
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# ARG instructions do not create additional layers. Instead, next layers will
|
# ARG instructions do not create additional layers. Instead, next layers will
|
||||||
# concatenate them. Also, we have to repeat ARG instructions in each build
|
# concatenate them. Also, we have to repeat ARG instructions in each build
|
||||||
# stage that uses them.
|
# stage that uses them.
|
||||||
ARG GOLANG_VERSION=1.26.2
|
ARG GOLANG_VERSION=1.26.5
|
||||||
|
|
||||||
# ----------------------------------------------
|
# ----------------------------------------------
|
||||||
# pdfcpu binary build stage
|
# pdfcpu binary build stage
|
||||||
@@ -11,7 +11,7 @@ ARG GOLANG_VERSION=1.26.2
|
|||||||
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
|
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
|
||||||
|
|
||||||
# See https://github.com/pdfcpu/pdfcpu/releases.
|
# See https://github.com/pdfcpu/pdfcpu/releases.
|
||||||
ARG PDFCPU_VERSION=v0.12.0
|
ARG PDFCPU_VERSION=v0.13.0
|
||||||
ENV CGO_ENABLED=0
|
ENV CGO_ENABLED=0
|
||||||
|
|
||||||
# Define the working directory outside of $GOPATH (we're using go modules).
|
# Define the working directory outside of $GOPATH (we're using go modules).
|
||||||
@@ -88,7 +88,7 @@ RUN apt-get update -qq \
|
|||||||
|
|
||||||
WORKDIR /downloads
|
WORKDIR /downloads
|
||||||
|
|
||||||
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.2.0/unoconv -o unoconverter \
|
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.3.0/unoconv -o unoconverter \
|
||||||
&& chmod +x unoconverter
|
&& chmod +x unoconverter
|
||||||
|
|
||||||
RUN curl -o pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" \
|
RUN curl -o pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" \
|
||||||
@@ -125,6 +125,7 @@ RUN groupadd --gid "$GOTENBERG_USER_GID" gotenberg \
|
|||||||
RUN apt-get update -qq \
|
RUN apt-get update -qq \
|
||||||
&& apt-get upgrade -yqq \
|
&& apt-get upgrade -yqq \
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
|
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
tini \
|
tini \
|
||||||
# Many users rely on curl for Docker health checks.
|
# Many users rely on curl for Docker health checks.
|
||||||
curl \
|
curl \
|
||||||
@@ -189,6 +190,19 @@ ENV QPDF_BIN_PATH=/usr/bin/qpdf
|
|||||||
ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool
|
ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool
|
||||||
ENV PDFCPU_BIN_PATH=/usr/bin/pdfcpu
|
ENV PDFCPU_BIN_PATH=/usr/bin/pdfcpu
|
||||||
|
|
||||||
|
# Capture backing-binary versions at build time so the running process reports
|
||||||
|
# them on traces without spawning the binaries at startup or per request.
|
||||||
|
# See pkg/gotenberg/buildversions.go. Chromium and LibreOffice are captured in
|
||||||
|
# the variant stages below, where they are installed.
|
||||||
|
ENV GOTENBERG_VERSIONS_DIR_PATH=/opt/gotenberg/versions
|
||||||
|
|
||||||
|
COPY --link build/capture-version.sh /opt/gotenberg/capture-version.sh
|
||||||
|
|
||||||
|
RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" qpdf "$QPDF_BIN_PATH" --version \
|
||||||
|
&& bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" exiftool "$EXIFTOOL_BIN_PATH" -ver \
|
||||||
|
&& bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" pdftk "$PDFTK_BIN_PATH" --version \
|
||||||
|
&& bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" pdfcpu "$PDFCPU_BIN_PATH" version
|
||||||
|
|
||||||
# OpenTelemetry defaults (noop - no telemetry overhead unless explicitly enabled).
|
# OpenTelemetry defaults (noop - no telemetry overhead unless explicitly enabled).
|
||||||
ENV OTEL_TRACES_EXPORTER=none
|
ENV OTEL_TRACES_EXPORTER=none
|
||||||
ENV OTEL_METRICS_EXPORTER=none
|
ENV OTEL_METRICS_EXPORTER=none
|
||||||
@@ -268,6 +282,10 @@ ENV CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data
|
|||||||
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
|
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
|
||||||
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
|
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
|
||||||
|
|
||||||
|
# Capture Chromium and LibreOffice versions now that both are installed.
|
||||||
|
RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" chromium "$CHROMIUM_BIN_PATH" --version \
|
||||||
|
&& bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" libreoffice-api "$LIBREOFFICE_BIN_PATH" --version
|
||||||
|
|
||||||
USER gotenberg
|
USER gotenberg
|
||||||
WORKDIR /home/gotenberg
|
WORKDIR /home/gotenberg
|
||||||
|
|
||||||
@@ -328,6 +346,9 @@ ENV CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data
|
|||||||
# No LibreOffice in this variant; override the default to use all available engines.
|
# No LibreOffice in this variant; override the default to use all available engines.
|
||||||
ENV PDFENGINES_CONVERT_ENGINES=
|
ENV PDFENGINES_CONVERT_ENGINES=
|
||||||
|
|
||||||
|
# Capture the Chromium version now that it is installed.
|
||||||
|
RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" chromium "$CHROMIUM_BIN_PATH" --version
|
||||||
|
|
||||||
USER gotenberg
|
USER gotenberg
|
||||||
WORKDIR /home/gotenberg
|
WORKDIR /home/gotenberg
|
||||||
|
|
||||||
@@ -382,6 +403,9 @@ COPY --link --from=downloader-stage /downloads/unoconverter /usr/bin/unoconverte
|
|||||||
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
|
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
|
||||||
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
|
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
|
||||||
|
|
||||||
|
# Capture the LibreOffice version now that it is installed.
|
||||||
|
RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" libreoffice-api "$LIBREOFFICE_BIN_PATH" --version
|
||||||
|
|
||||||
USER gotenberg
|
USER gotenberg
|
||||||
WORKDIR /home/gotenberg
|
WORKDIR /home/gotenberg
|
||||||
|
|
||||||
|
|||||||
34
build/capture-version.sh
Normal file
34
build/capture-version.sh
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Captures the version of a backing binary into a per-module file that the
|
||||||
|
# running Gotenberg process reads via gotenberg.BuildVersion, so it never spawns
|
||||||
|
# the binary just to report a version. This keeps cold start and the first
|
||||||
|
# request cheap, which matters on serverless platforms.
|
||||||
|
#
|
||||||
|
# Failure-tolerant by design: a probe that errors writes an empty file, and the
|
||||||
|
# runtime falls back to detecting the version live. A failing probe must never
|
||||||
|
# fail the image build.
|
||||||
|
#
|
||||||
|
# Usage: capture-version.sh <output-dir> <module-id> <bin> [args...]
|
||||||
|
set -u
|
||||||
|
|
||||||
|
dir="$1"
|
||||||
|
id="$2"
|
||||||
|
shift 2
|
||||||
|
|
||||||
|
mkdir -p "$dir"
|
||||||
|
|
||||||
|
# Run the probe once. On failure, keep going with empty output.
|
||||||
|
raw="$("$@" 2>/dev/null)" || raw=""
|
||||||
|
|
||||||
|
case "$id" in
|
||||||
|
pdfcpu)
|
||||||
|
# pdfcpu prints "pdfcpu: <version>"; keep only the part the runtime parser
|
||||||
|
# keeps so the recorded value matches the live-detection fallback.
|
||||||
|
version="$(printf '%s\n' "$raw" | grep -m1 '^pdfcpu:' | sed 's/^pdfcpu:[[:space:]]*//')"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
version="$(printf '%s\n' "$raw" | head -n1)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
printf '%s' "$version" | tr -d '\r' >"$dir/$id"
|
||||||
@@ -49,6 +49,7 @@ func Run() {
|
|||||||
fs.String("log-fields-prefix", "", "Prepend a specified prefix to each log field key")
|
fs.String("log-fields-prefix", "", "Prepend a specified prefix to each log field key")
|
||||||
fs.String("log-std-format", gotenberg.AutoLoggingFormat, "Set the log format for standard output")
|
fs.String("log-std-format", gotenberg.AutoLoggingFormat, "Set the log format for standard output")
|
||||||
fs.Bool("log-std-enable-gcp-fields", false, "Use GCP-compatible field names in log output")
|
fs.Bool("log-std-enable-gcp-fields", false, "Use GCP-compatible field names in log output")
|
||||||
|
fs.String("log-std-level-case", gotenberg.LowerLevelCase, "Set the case of the level field in the standard output, either lower or upper")
|
||||||
|
|
||||||
// Deprecated logging flags.
|
// Deprecated logging flags.
|
||||||
fs.String("log-format", gotenberg.AutoLoggingFormat, "Set the log format")
|
fs.String("log-format", gotenberg.AutoLoggingFormat, "Set the log format")
|
||||||
@@ -123,6 +124,7 @@ func Run() {
|
|||||||
LogFieldsPrefix: parsedFlags.MustString("log-fields-prefix"),
|
LogFieldsPrefix: parsedFlags.MustString("log-fields-prefix"),
|
||||||
LogStdFormat: parsedFlags.MustDeprecatedString("log-format", "log-std-format"),
|
LogStdFormat: parsedFlags.MustDeprecatedString("log-format", "log-std-format"),
|
||||||
LogStdEnableGcpFields: parsedFlags.MustDeprecatedBool("log-enable-gcp-fields", "log-std-enable-gcp-fields"),
|
LogStdEnableGcpFields: parsedFlags.MustDeprecatedBool("log-enable-gcp-fields", "log-std-enable-gcp-fields"),
|
||||||
|
LogStdLevelCase: parsedFlags.MustString("log-std-level-case"),
|
||||||
}
|
}
|
||||||
// LogLevel uses its own flag, not the format flag.
|
// LogLevel uses its own flag, not the format flag.
|
||||||
telemetryCfg.LogLevel = parsedFlags.MustString("log-level")
|
telemetryCfg.LogLevel = parsedFlags.MustString("log-level")
|
||||||
|
|||||||
16
compose.yaml
16
compose.yaml
@@ -21,6 +21,8 @@ services:
|
|||||||
- "--api-port=${API_PORT}"
|
- "--api-port=${API_PORT}"
|
||||||
- "--api-port-from-env=${API_PORT_FROM_ENV}"
|
- "--api-port-from-env=${API_PORT_FROM_ENV}"
|
||||||
- "--api-bind-ip=${API_BIND_IP}"
|
- "--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-start-timeout=${API_START_TIMEOUT}"
|
||||||
- "--api-timeout=${API_TIMEOUT}"
|
- "--api-timeout=${API_TIMEOUT}"
|
||||||
- "--api-body-limit=${API_BODY_LIMIT}"
|
- "--api-body-limit=${API_BODY_LIMIT}"
|
||||||
@@ -29,6 +31,9 @@ services:
|
|||||||
- "--api-enable-basic-auth=${API_ENABLE_BASIC_AUTH}"
|
- "--api-enable-basic-auth=${API_ENABLE_BASIC_AUTH}"
|
||||||
- "--api-download-from-allow-list=${API_DOWNLOAD_FROM_ALLOW_LIST}"
|
- "--api-download-from-allow-list=${API_DOWNLOAD_FROM_ALLOW_LIST}"
|
||||||
- "--api-download-from-deny-list=${API_DOWNLOAD_FROM_DENY_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-download-from-max-retry=${API_DOWNLOAD_FROM_MAX_RETRY}"
|
||||||
- "--api-disable-download-from=${API_DISABLE_DOWNLOAD_FROM}"
|
- "--api-disable-download-from=${API_DISABLE_DOWNLOAD_FROM}"
|
||||||
- "--api-disable-health-check-route-telemetry=${API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY}"
|
- "--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-allow-file-access-from-files=${CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES}"
|
||||||
- "--chromium-host-resolver-rules=${CHROMIUM_HOST_RESOLVER_RULES}"
|
- "--chromium-host-resolver-rules=${CHROMIUM_HOST_RESOLVER_RULES}"
|
||||||
- "--chromium-proxy-server=${CHROMIUM_PROXY_SERVER}"
|
- "--chromium-proxy-server=${CHROMIUM_PROXY_SERVER}"
|
||||||
|
- "--chromium-enable-environment-proxy=${CHROMIUM_ENABLE_ENVIRONMENT_PROXY}"
|
||||||
- "--chromium-allow-list=${CHROMIUM_ALLOW_LIST}"
|
- "--chromium-allow-list=${CHROMIUM_ALLOW_LIST}"
|
||||||
- "--chromium-deny-list=${CHROMIUM_DENY_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-cache=${CHROMIUM_CLEAR_CACHE}"
|
||||||
- "--chromium-clear-cookies=${CHROMIUM_CLEAR_COOKIES}"
|
- "--chromium-clear-cookies=${CHROMIUM_CLEAR_COOKIES}"
|
||||||
- "--chromium-disable-javascript=${CHROMIUM_DISABLE_JAVASCRIPT}"
|
- "--chromium-disable-javascript=${CHROMIUM_DISABLE_JAVASCRIPT}"
|
||||||
@@ -61,11 +69,15 @@ services:
|
|||||||
- "--libreoffice-start-timeout=${LIBREOFFICE_START_TIMEOUT}"
|
- "--libreoffice-start-timeout=${LIBREOFFICE_START_TIMEOUT}"
|
||||||
- "--libreoffice-allow-list=${LIBREOFFICE_ALLOW_LIST}"
|
- "--libreoffice-allow-list=${LIBREOFFICE_ALLOW_LIST}"
|
||||||
- "--libreoffice-deny-list=${LIBREOFFICE_DENY_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}"
|
- "--libreoffice-disable-routes=${LIBREOFFICE_DISABLE_ROUTES}"
|
||||||
- "--log-level=${LOG_LEVEL}"
|
- "--log-level=${LOG_LEVEL}"
|
||||||
- "--log-fields-prefix=${LOG_FIELDS_PREFIX}"
|
- "--log-fields-prefix=${LOG_FIELDS_PREFIX}"
|
||||||
- "--log-std-format=${LOG_STD_FORMAT}"
|
- "--log-std-format=${LOG_STD_FORMAT}"
|
||||||
- "--log-std-enable-gcp-fields=${LOG_STD_ENABLE_GCP_FIELDS}"
|
- "--log-std-enable-gcp-fields=${LOG_STD_ENABLE_GCP_FIELDS}"
|
||||||
|
- "--log-std-level-case=${LOG_STD_LEVEL_CASE}"
|
||||||
- "--pdfengines-merge-engines=${PDFENGINES_MERGE_ENGINES}"
|
- "--pdfengines-merge-engines=${PDFENGINES_MERGE_ENGINES}"
|
||||||
- "--pdfengines-split-engines=${PDFENGINES_SPLIT_ENGINES}"
|
- "--pdfengines-split-engines=${PDFENGINES_SPLIT_ENGINES}"
|
||||||
- "--pdfengines-flatten-engines=${PDFENGINES_FLATTEN_ENGINES}"
|
- "--pdfengines-flatten-engines=${PDFENGINES_FLATTEN_ENGINES}"
|
||||||
@@ -80,6 +92,7 @@ services:
|
|||||||
- "--pdfengines-rotate-engines=${PDFENGINES_ROTATE_ENGINES}"
|
- "--pdfengines-rotate-engines=${PDFENGINES_ROTATE_ENGINES}"
|
||||||
- "--pdfengines-embed-engines=${PDFENGINES_EMBED_ENGINES}"
|
- "--pdfengines-embed-engines=${PDFENGINES_EMBED_ENGINES}"
|
||||||
- "--pdfengines-embed-metadata-engines=${PDFENGINES_EMBED_METADATA_ENGINES}"
|
- "--pdfengines-embed-metadata-engines=${PDFENGINES_EMBED_METADATA_ENGINES}"
|
||||||
|
- "--pdfengines-factur-x-engines=${PDFENGINES_FACTUR_X_ENGINES}"
|
||||||
- "--pdfengines-disable-routes=${PDFENGINES_DISABLE_ROUTES}"
|
- "--pdfengines-disable-routes=${PDFENGINES_DISABLE_ROUTES}"
|
||||||
- "--prometheus-namespace=${PROMETHEUS_NAMESPACE}"
|
- "--prometheus-namespace=${PROMETHEUS_NAMESPACE}"
|
||||||
- "--prometheus-collect-interval=${PROMETHEUS_COLLECT_INTERVAL}"
|
- "--prometheus-collect-interval=${PROMETHEUS_COLLECT_INTERVAL}"
|
||||||
@@ -89,6 +102,9 @@ services:
|
|||||||
- "--webhook-enable-sync-mode=${WEBHOOK_ENABLE_SYNC_MODE}"
|
- "--webhook-enable-sync-mode=${WEBHOOK_ENABLE_SYNC_MODE}"
|
||||||
- "--webhook-allow-list=${WEBHOOK_ALLOW_LIST}"
|
- "--webhook-allow-list=${WEBHOOK_ALLOW_LIST}"
|
||||||
- "--webhook-deny-list=${WEBHOOK_DENY_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-max-retry=${WEBHOOK_MAX_RETRY}"
|
||||||
- "--webhook-retry-min-wait=${WEBHOOK_RETRY_MIN_WAIT}"
|
- "--webhook-retry-min-wait=${WEBHOOK_RETRY_MIN_WAIT}"
|
||||||
- "--webhook-retry-max-wait=${WEBHOOK_RETRY_MAX_WAIT}"
|
- "--webhook-retry-max-wait=${WEBHOOK_RETRY_MAX_WAIT}"
|
||||||
|
|||||||
125
go.mod
125
go.mod
@@ -4,36 +4,37 @@ go 1.26.2
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alexliesenfeld/health v0.8.1
|
github.com/alexliesenfeld/health v0.8.1
|
||||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
|
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d // pinned with chromedp v0.14.2, see below
|
||||||
github.com/chromedp/chromedp v0.14.2
|
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/cucumber/godog v0.16.0
|
||||||
github.com/dlclark/regexp2 v1.12.0
|
github.com/dlclark/regexp2 v1.12.0
|
||||||
github.com/gomarkdown/markdown v0.0.0-20260412113850-134a5b2cce7f
|
github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/hashicorp/go-retryablehttp v0.7.8
|
github.com/hashicorp/go-retryablehttp v0.7.8
|
||||||
github.com/labstack/echo/v4 v4.15.1
|
github.com/labstack/echo/v4 v4.15.4
|
||||||
github.com/labstack/gommon v0.5.0
|
github.com/labstack/gommon v0.5.0
|
||||||
github.com/mholt/archives v0.1.5
|
github.com/mholt/archives v0.1.5
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27
|
github.com/microcosm-cc/bluemonday v1.0.27
|
||||||
github.com/moby/moby/api v1.54.1
|
github.com/moby/moby/api v1.55.0
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/moby/moby/client v0.5.1
|
||||||
github.com/shirou/gopsutil/v4 v4.26.3
|
github.com/prometheus/client_golang v1.24.1
|
||||||
|
github.com/shirou/gopsutil/v4 v4.26.7
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/testcontainers/testcontainers-go v0.42.0
|
github.com/testcontainers/testcontainers-go v0.43.0
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0
|
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0
|
||||||
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0
|
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0
|
||||||
go.opentelemetry.io/otel v1.43.0
|
go.opentelemetry.io/otel v1.45.0
|
||||||
go.opentelemetry.io/otel/log v0.19.0
|
go.opentelemetry.io/otel/log v0.20.0
|
||||||
go.opentelemetry.io/otel/metric v1.43.0
|
go.opentelemetry.io/otel/metric v1.45.0
|
||||||
go.opentelemetry.io/otel/sdk v1.43.0
|
go.opentelemetry.io/otel/sdk v1.45.0
|
||||||
go.opentelemetry.io/otel/sdk/log v0.19.0
|
go.opentelemetry.io/otel/sdk/log v0.20.0
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0
|
go.opentelemetry.io/otel/sdk/metric v1.45.0
|
||||||
go.opentelemetry.io/otel/trace v1.43.0
|
go.opentelemetry.io/otel/trace v1.45.0
|
||||||
golang.org/x/net v0.53.0
|
golang.org/x/net v0.57.0
|
||||||
golang.org/x/sync v0.20.0
|
golang.org/x/sync v0.22.0
|
||||||
golang.org/x/term v0.42.0
|
golang.org/x/term v0.45.0
|
||||||
golang.org/x/text v0.36.0
|
golang.org/x/text v0.40.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -45,7 +46,7 @@ require (
|
|||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bodgit/plumbing v1.3.0 // indirect
|
github.com/bodgit/plumbing v1.3.0 // indirect
|
||||||
github.com/bodgit/sevenzip v1.6.1 // indirect
|
github.com/bodgit/sevenzip v1.6.4 // indirect
|
||||||
github.com/bodgit/windows v1.0.1 // indirect
|
github.com/bodgit/windows v1.0.1 // indirect
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
@@ -56,89 +57,87 @@ require (
|
|||||||
github.com/containerd/log v0.1.0 // indirect
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
github.com/containerd/platforms v0.2.1 // indirect
|
github.com/containerd/platforms v0.2.1 // indirect
|
||||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||||
github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect
|
github.com/cucumber/gherkin/go/v42 v42.0.0 // indirect
|
||||||
github.com/cucumber/messages/go/v21 v21.0.1 // indirect
|
github.com/cucumber/messages/go/v34 v34.2.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/distribution/reference v0.6.0 // indirect
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
github.com/docker/go-connections v0.6.0 // indirect
|
github.com/docker/go-connections v0.7.0 // indirect
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
||||||
github.com/ebitengine/purego v0.10.0 // indirect
|
github.com/ebitengine/purego v0.10.2 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
|
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.4 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/gobwas/httphead v0.1.0 // indirect
|
github.com/gobwas/httphead v0.1.0 // indirect
|
||||||
github.com/gobwas/pool v0.2.1 // indirect
|
github.com/gobwas/pool v0.2.1 // indirect
|
||||||
github.com/gobwas/ws v1.4.0 // indirect
|
github.com/gobwas/ws v1.4.0 // indirect
|
||||||
github.com/gofrs/uuid v4.4.0+incompatible // indirect
|
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
||||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||||
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
||||||
github.com/hashicorp/go-memdb v1.3.5 // indirect
|
github.com/hashicorp/go-memdb v1.3.5 // indirect
|
||||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
github.com/klauspost/compress v1.18.5 // indirect
|
github.com/klauspost/compress v1.19.1 // indirect
|
||||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
||||||
github.com/magiconair/properties v1.8.10 // indirect
|
github.com/magiconair/properties v1.8.10 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||||
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
||||||
github.com/minio/minlz v1.1.0 // indirect
|
github.com/minio/minlz v1.1.1 // indirect
|
||||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
github.com/moby/go-archive v0.2.0 // indirect
|
github.com/moby/go-archive v0.2.0 // indirect
|
||||||
github.com/moby/moby/client v0.4.0 // indirect
|
|
||||||
github.com/moby/patternmatcher v0.6.1 // indirect
|
github.com/moby/patternmatcher v0.6.1 // indirect
|
||||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
github.com/moby/sys/sequential v0.7.0 // indirect
|
||||||
github.com/moby/sys/user v0.4.0 // indirect
|
github.com/moby/sys/user v0.4.0 // indirect
|
||||||
github.com/moby/sys/userns v0.1.0 // indirect
|
github.com/moby/sys/userns v0.1.0 // indirect
|
||||||
github.com/moby/term v0.5.2 // indirect
|
github.com/moby/term v0.5.2 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nwaples/rardecode/v2 v2.2.2 // indirect
|
github.com/nwaples/rardecode/v2 v2.2.5 // indirect
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
github.com/pierrec/lz4/v4 v4.1.26 // indirect
|
github.com/pierrec/lz4/v4 v4.1.27 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.5 // indirect
|
github.com/prometheus/common v0.70.1 // indirect
|
||||||
github.com/prometheus/otlptranslator v1.0.0 // 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/sirupsen/logrus v1.9.4 // indirect
|
||||||
github.com/sorairolake/lzip-go v0.3.8 // indirect
|
github.com/sorairolake/lzip-go v0.3.8 // indirect
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
github.com/stangelandcl/ppmd v0.1.1 // indirect
|
||||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||||
|
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 // indirect
|
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 // indirect
|
go.opentelemetry.io/otel/exporters/prometheus v0.66.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0 // indirect
|
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 // indirect
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 // indirect
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.10.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
|
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
|
||||||
golang.org/x/crypto v0.50.0 // indirect
|
golang.org/x/crypto v0.54.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/time v0.15.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 // indirect
|
||||||
google.golang.org/grpc v1.80.0 // indirect
|
google.golang.org/grpc v1.82.1 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
261
go.sum
261
go.sum
@@ -18,8 +18,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
|||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
|
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
|
||||||
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
|
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
|
||||||
github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4=
|
github.com/bodgit/sevenzip v1.6.4 h1:iHiVJfxbrB6RF4X+snI2MpVgNBKmVfGaTqZGNlMQIU0=
|
||||||
github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8=
|
github.com/bodgit/sevenzip v1.6.4/go.mod h1:ZtNi5KNgHXeXg1G7WiF0IWSuFE2eG6lt/cTGlvuirO0=
|
||||||
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
|
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
|
||||||
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
|
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
@@ -44,16 +44,14 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS
|
|||||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
|
||||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI=
|
github.com/cucumber/gherkin/go/v42 v42.0.0 h1:Ulh3E2awUUSSja+wonP/IOQ+ycmiZwZbgmzqk5H8JNI=
|
||||||
github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0=
|
github.com/cucumber/gherkin/go/v42 v42.0.0/go.mod h1:CsaumaO2dR9XvBc6ZyiGLMhWCKtTRDxgoxqJigSjSSg=
|
||||||
github.com/cucumber/godog v0.15.1 h1:rb/6oHDdvVZKS66hrhpjFQFHjthFSrQBCOI1LwshNTI=
|
github.com/cucumber/godog v0.16.0 h1:ezQbgItuWqZrjPUQwLJ3muwIlvzXBOfZso5QZfG7efE=
|
||||||
github.com/cucumber/godog v0.15.1/go.mod h1:qju+SQDewOljHuq9NSM66s0xEhogx0q30flfxL4WUk8=
|
github.com/cucumber/godog v0.16.0/go.mod h1:EDUX9yCqANK+GpbftMDeu61sUDtdLuo1JJgXD2n3bbM=
|
||||||
github.com/cucumber/messages/go/v21 v21.0.1 h1:wzA0LxwjlWQYZd32VTlAVDTkW6inOFmSM+RuOwHZiMI=
|
github.com/cucumber/messages/go/v34 v34.2.0 h1:VCbcNOMz+f8ccjjOOx1NLBNhwvE7/X49Atc8klJa+i8=
|
||||||
github.com/cucumber/messages/go/v21 v21.0.1/go.mod h1:zheH/2HS9JLVFukdrsPWoPdmUtmYQAQPLk7w5vWsk5s=
|
github.com/cucumber/messages/go/v34 v34.2.0/go.mod h1:LYUPjqlTS1kS0pdkdf6sS5uirnjwiIzEGyXPezXNhL8=
|
||||||
github.com/cucumber/messages/go/v22 v22.0.0/go.mod h1:aZipXTKc0JnjCsXrJnuZpWhtay93k7Rn3Dee7iyPJjs=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -61,24 +59,24 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
|
|||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
||||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
|
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
|
||||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
|
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
|
||||||
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
|
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
|
||||||
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
|
||||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
||||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
|
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6 h1:nxP4pPoyqOAgX8lYDFCfl3DyKeXErCvSvhcyzwGV9CE=
|
||||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
@@ -90,14 +88,10 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
|||||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||||
github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
|
||||||
github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
|
||||||
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
|
|
||||||
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 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/gomarkdown/markdown v0.0.0-20260412113850-134a5b2cce7f h1:C5vKBogs/Qf5ID8F8XuRO8SFL+5SH7JMJrAfdLAZ2iA=
|
github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76 h1:Ltt9ldIaSYEsjA7sPY2c8r9dOmnKM1vlzhh3dxlhBHM=
|
||||||
github.com/gomarkdown/markdown v0.0.0-20260412113850-134a5b2cce7f/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
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.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
@@ -105,16 +99,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
||||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||||
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||||
github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
|
||||||
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
|
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
|
||||||
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||||
github.com/hashicorp/go-memdb v1.3.4/go.mod h1:uBTr1oQbtuMgd1SSGoR8YV27eT3sBHbYiNm53bMpgSg=
|
|
||||||
github.com/hashicorp/go-memdb v1.3.5 h1:b3taDMxCBCBVgyRrS1AZVHO14ubMYZB++QpNhBg+Nyo=
|
github.com/hashicorp/go-memdb v1.3.5 h1:b3taDMxCBCBVgyRrS1AZVHO14ubMYZB++QpNhBg+Nyo=
|
||||||
github.com/hashicorp/go-memdb v1.3.5/go.mod h1:8IVKKBkVe+fxFgdFOYxzQQNjz+sWCyHCdIC/+5+Vy1Y=
|
github.com/hashicorp/go-memdb v1.3.5/go.mod h1:8IVKKBkVe+fxFgdFOYxzQQNjz+sWCyHCdIC/+5+Vy1Y=
|
||||||
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
|
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
|
||||||
@@ -128,24 +120,20 @@ github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iP
|
|||||||
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
|
||||||
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||||
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
||||||
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs=
|
github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs=
|
||||||
github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
|
github.com/labstack/echo/v4 v4.15.4/go.mod h1:CuMetKIRwsuO/qlAgMq+KTAalwGoB/h4tC+yPdrTj1g=
|
||||||
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
|
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
|
||||||
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
|
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
|
||||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||||
@@ -154,30 +142,30 @@ 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/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||||
github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
|
github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
|
||||||
github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4=
|
github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0=
|
github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0=
|
||||||
github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
|
github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
|
||||||
github.com/minio/minlz v1.1.0 h1:rUOGu3EP4EqJC5k3qCsIwEnZiJULKqtRyDdqbhlvMmQ=
|
github.com/minio/minlz v1.1.1 h1:OGmft1V6AnI/Wme332U6bhG54nxEan+VFgkD7lat4KM=
|
||||||
github.com/minio/minlz v1.1.0/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
|
github.com/minio/minlz v1.1.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
|
||||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
||||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
||||||
github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
|
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
||||||
github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||||
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
|
github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw=
|
||||||
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
|
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 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||||
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
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.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
|
||||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
|
||||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||||
@@ -186,46 +174,44 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
|||||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nwaples/rardecode/v2 v2.2.2 h1:/5oL8dzYivRM/tqX9VcTSWfbpwcbwKG1QtSJr3b3KcU=
|
github.com/nwaples/rardecode/v2 v2.2.5 h1:L5doqgGfQwI7qADJMqnkrSB86rpPsqQDrHeO0HWa5JY=
|
||||||
github.com/nwaples/rardecode/v2 v2.2.2/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
|
github.com/nwaples/rardecode/v2 v2.2.5/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
|
||||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||||
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
|
github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
|
||||||
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
||||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
|
||||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
|
||||||
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
|
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/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.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
|
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
|
||||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||||
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
|
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
|
||||||
github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU=
|
github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU=
|
||||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
|
||||||
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
|
||||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/stangelandcl/ppmd v0.1.1 h1:c25QazhlWUn5nmR1QOzafKhQxBicAr7GGCKER2aJ8H8=
|
||||||
|
github.com/stangelandcl/ppmd v0.1.1/go.mod h1:Rrv7M+/2P5jYr/GMLhBl7Ug3uJ1bUiVzr5LbbaV6xgY=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -234,15 +220,14 @@ github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+Q
|
|||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
|
github.com/testcontainers/testcontainers-go v0.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A=
|
||||||
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
|
github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo=
|
||||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
||||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
||||||
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
@@ -256,52 +241,54 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
|
|||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0 h1:hhPGP3zvvy1xWT9RTy970wlniSxFttBIsAK1gvMguJM=
|
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 h1:5RgvxieNq9tS3ewrV1vnODvbHPfKUIJcYtF9Cvz+6aQ=
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0/go.mod h1:twJF7inoMza6kxMcF8JOdL3mPmtOZu7GEr34CUNE6Dg=
|
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0/go.mod h1:iTBIdNwx/xmUhfgJs6+84S4dIK059811cO1eUBjKcHY=
|
||||||
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 h1:w3zlHYETbDwXyWHZlyyR58ZC39XGi8rAhkBgUgJ9d5w=
|
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0 h1:saQoWg5845Q8TojpqeVStS7zGwVZ6bc5W2PJavTPiBM=
|
||||||
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0/go.mod h1:GR/mClR2nn7vE8RLwxKjoBNg+QtgdDhRzxVa93koy5o=
|
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0/go.mod h1:AAaS6xs5AyqMdR3Ir0nSWK+QudL2XM8Vbw5INzUxNc8=
|
||||||
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0 h1:0D3GFvELGIwQGfC6agLsbrEYSGWZTRTxIXxcQUqrOuk=
|
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0 h1:R3jsCoTIzv0BiYNhW0axyswn/6SMJ8xL1OuGxvni1Kw=
|
||||||
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0/go.mod h1:DM2NV7Zb8CcGeVPt6glouY0FAiwZQ/iqgcWExhgWeN8=
|
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0/go.mod h1:m07gqyr2QhQxKOKb5vqKCCBtLH3uqlNYR7PU/FISXVU=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
|
||||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
|
||||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 h1:Dn8rkudDzY6KV9dr/D/bTUuWgqDf9xe0rr4G2elrn0Y=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0/go.mod h1:gMk9F0xDgyN9M/3Ed5Y1wKcx/9mlU91NXY2SNq7RQuU=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0/go.mod h1:MP4eemTiI9zC8fgg+DYynhYDYf3ba72S376TvP+Ye0Q=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY=
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg=
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
|
||||||
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE=
|
go.opentelemetry.io/otel/exporters/prometheus v0.66.0 h1:vkrK8PAznv2NKt2r+kdu252ccGzkEqLc2aSXbQIALYQ=
|
||||||
go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo=
|
go.opentelemetry.io/otel/exporters/prometheus v0.66.0/go.mod h1:V/UB6D3vMF/UBOL5igAsAYnk1nG/bzYYTzvsB16cy7o=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0 h1:GJkybS+crDMdExT/BUNCEgfrmfboztcS6PhvSo88HKM=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 h1:aZfdmtI6QU/DAPD4b7YZ5zuJgewxO1EW9miOZklqleU=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0/go.mod h1:NuAyxRYIG2lKX3YQkB+83StTxM7s52PUUkRRiC0wnYI=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0/go.mod h1:isNl10/Om5CBWu9jj8WOb2+tJLbCVXDgqwzCaJMnJ6w=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0=
|
||||||
go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4=
|
go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs=
|
||||||
go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk=
|
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E=
|
||||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
|
||||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
|
||||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns=
|
||||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI=
|
||||||
go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko=
|
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
|
||||||
go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg=
|
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
|
||||||
go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk=
|
go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY=
|
||||||
go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk=
|
go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M=
|
||||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
|
||||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
|
||||||
|
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
|
||||||
|
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
|
||||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
@@ -310,34 +297,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=
|
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 h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
|
||||||
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
|
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
|
||||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
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 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d h1:/aDRtSZJjyLQzm75d+a1wOJaqyKBMvIAfeQmoa3ORiI=
|
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-20260406210006-6f92a3bedf2d/go.mod h1:etfGUgejTiadZAUaEP14NP97xi1RGeawqkjDARA/UOs=
|
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-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 h1:9HZDLIdYBJXAnaFOr9WHrKVycfpY+75s9HGadC0305A=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
107
package-lock.json
generated
107
package-lock.json
generated
@@ -5,9 +5,9 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.9.6",
|
||||||
"prettier-plugin-gherkin": "^3.1.3",
|
"prettier-plugin-gherkin": "^3.1.3",
|
||||||
"prettier-plugin-sh": "^0.18.0"
|
"prettier-plugin-sh": "^0.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cucumber/gherkin": {
|
"node_modules/@cucumber/gherkin": {
|
||||||
@@ -34,15 +34,80 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@reteps/dockerfmt": {
|
"node_modules/@reteps/dockerfmt": {
|
||||||
"version": "0.5.2",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.5.4.tgz",
|
||||||
"integrity": "sha512-Hbr7yen4fP5TxGM54ucXa4o5NwWXatJ6Bd9I8gp0PValYbI4Rug2Gu+rVv7K7o/efQc3F5ctqWJz47rYaa8zBw==",
|
"integrity": "sha512-HEGgXVVOb+JtGUSSzXl/XPKFIZjMDTUoHarCjaQdkY+cb5M9K/O3b5xm+x0IPIk3SfHurbc0bSgcFsQlzjitxA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"dockerfmt": "dist/launcher.js"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^v12.20.0 || ^14.13.0 || >=16.0.0"
|
"node": "^v12.20.0 || ^14.13.0 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@reteps/dockerfmt-darwin-arm64": "0.5.4",
|
||||||
|
"@reteps/dockerfmt-darwin-x64": "0.5.4",
|
||||||
|
"@reteps/dockerfmt-linux-arm64": "0.5.4",
|
||||||
|
"@reteps/dockerfmt-linux-x64": "0.5.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reteps/dockerfmt-darwin-arm64": {
|
||||||
|
"version": "0.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt-darwin-arm64/-/dockerfmt-darwin-arm64-0.5.4.tgz",
|
||||||
|
"integrity": "sha512-urMqV+dQyvVI8/WrXwClX9e1PEyS35wFdwJjpZYmL09AkV4Io5U1oam8UBKK7jZk0+YsdF88ay6e86Kn6DIyQg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@reteps/dockerfmt-darwin-x64": {
|
||||||
|
"version": "0.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt-darwin-x64/-/dockerfmt-darwin-x64-0.5.4.tgz",
|
||||||
|
"integrity": "sha512-fJORy6DFxbgDiMqxpLTPZlb5KUY0Vq0iR4NGnyKnuYZ9LdZUS508DK2kt/AJ87/jIKNV1qRG0JXG1Tc6xdvWjw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@reteps/dockerfmt-linux-arm64": {
|
||||||
|
"version": "0.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt-linux-arm64/-/dockerfmt-linux-arm64-0.5.4.tgz",
|
||||||
|
"integrity": "sha512-6pVakO06eXtDuvxy1Dnjs/gQyUoGGycle8PRSt5IFRwLi/AVaOQwfkfmW0WP8VH9wNUeti6BfJ3ksTn2G+XMxg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@reteps/dockerfmt-linux-x64": {
|
||||||
|
"version": "0.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt-linux-x64/-/dockerfmt-linux-x64-0.5.4.tgz",
|
||||||
|
"integrity": "sha512-OD6SIlUV1D4TgJoTui3FMBAZsGbTSPYsiT0BKhD6jMUcJb3GpFTa7dY9rL8rP9FUqfL7OTHVUGUOL4Rh64Olog==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@types/uuid": {
|
"node_modules/@types/uuid": {
|
||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||||
@@ -58,9 +123,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/prettier": {
|
"node_modules/prettier": {
|
||||||
"version": "3.8.3",
|
"version": "3.9.6",
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
|
||||||
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -86,14 +151,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prettier-plugin-sh": {
|
"node_modules/prettier-plugin-sh": {
|
||||||
"version": "0.18.1",
|
"version": "0.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.19.0.tgz",
|
||||||
"integrity": "sha512-uZmU22wBMevjh3rmCatNQqiEer2+5KLa0xYCBX6zQQUQkcNzVL+s6FbPKK6ZSUNUbQk6jMAcQHrYPvuL2W6ihQ==",
|
"integrity": "sha512-39VXFZH/cOGtcuu8aeSvqp/hhwomOR4QroZUj+jBz2cNb3os9s0sqFZSNlYts6jdtLLDU7D2YT3Z1+abtb7adQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@reteps/dockerfmt": "^0.5.1",
|
"@reteps/dockerfmt": "^0.5.4",
|
||||||
"sh-syntax": "^0.5.8"
|
"sh-syntax": "^0.6.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
@@ -113,14 +178,11 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/sh-syntax": {
|
"node_modules/sh-syntax": {
|
||||||
"version": "0.5.8",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.6.0.tgz",
|
||||||
"integrity": "sha512-JfVoxf4FxQI5qpsPbkHhZo+n6N9YMJobyl4oGEUBb/31oQYlgTjkXQD8PBiafS2UbWoxrTO0Z5PJUBXEPAG1Zw==",
|
"integrity": "sha512-52VK6z/cdZHv7UURjIcwfBUQZrAhIEEe0bY4lrkfypjnFIKsDZdD3Uaz/dBiw/sF8BeX0Mssv140s8EnrsJ9dQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.8.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
},
|
},
|
||||||
@@ -128,13 +190,6 @@
|
|||||||
"url": "https://opencollective.com/sh-syntax"
|
"url": "https://opencollective.com/sh-syntax"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tslib": {
|
|
||||||
"version": "2.8.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "0BSD"
|
|
||||||
},
|
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "11.0.5",
|
"version": "11.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.9.6",
|
||||||
"prettier-plugin-gherkin": "^3.1.3",
|
"prettier-plugin-gherkin": "^3.1.3",
|
||||||
"prettier-plugin-sh": "^0.18.0"
|
"prettier-plugin-sh": "^0.19.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
53
pkg/gotenberg/attrs.go
Normal file
53
pkg/gotenberg/attrs.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package gotenberg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxAttrRunes bounds the length of a string span attribute to keep payload
|
||||||
|
// size and backend cardinality in check.
|
||||||
|
const maxAttrRunes = 256
|
||||||
|
|
||||||
|
// CapAttr truncates s to at most [maxAttrRunes] runes, appending an ellipsis
|
||||||
|
// when it shortens the value. It is multibyte-safe.
|
||||||
|
func CapAttr(s string) string {
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= maxAttrRunes {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(runes[:maxAttrRunes-1]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedactURL parses raw and returns a redacted, length-capped form safe to use
|
||||||
|
// as a span attribute or event value. Userinfo, query, and fragment are
|
||||||
|
// dropped because they may carry credentials or other sensitive data. It
|
||||||
|
// returns an empty string when raw is empty or cannot be parsed.
|
||||||
|
func RedactURL(raw string) string {
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed.User = nil
|
||||||
|
parsed.RawQuery = ""
|
||||||
|
parsed.ForceQuery = false
|
||||||
|
parsed.Fragment = ""
|
||||||
|
parsed.RawFragment = ""
|
||||||
|
|
||||||
|
return CapAttr(parsed.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapEnum returns value when it belongs to allowed, otherwise "other". It keeps
|
||||||
|
// a span attribute or metric dimension bounded even when an upstream tool
|
||||||
|
// introduces a new enum value.
|
||||||
|
func MapEnum(value string, allowed ...string) string {
|
||||||
|
if slices.Contains(allowed, value) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return "other"
|
||||||
|
}
|
||||||
62
pkg/gotenberg/attrs_test.go
Normal file
62
pkg/gotenberg/attrs_test.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package gotenberg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRedactURL(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"empty", "", ""},
|
||||||
|
{"strips userinfo query fragment", "https://user:pass@example.com/path?token=secret#frag", "https://example.com/path"},
|
||||||
|
{"keeps host and path", "http://example.com/a/b", "http://example.com/a/b"},
|
||||||
|
{"parse error", "http://example.com/%zz", ""},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := RedactURL(tc.raw); got != tc.want {
|
||||||
|
t.Errorf("RedactURL(%q) = %q, want %q", tc.raw, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedactURL_Caps(t *testing.T) {
|
||||||
|
raw := "https://example.com/" + strings.Repeat("a", 400)
|
||||||
|
got := RedactURL(raw)
|
||||||
|
if n := len([]rune(got)); n != maxAttrRunes {
|
||||||
|
t.Errorf("expected capped length %d, got %d", maxAttrRunes, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapAttr(t *testing.T) {
|
||||||
|
t.Run("short unchanged", func(t *testing.T) {
|
||||||
|
if got := CapAttr("short"); got != "short" {
|
||||||
|
t.Errorf("expected unchanged, got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("multibyte truncated to rune cap", func(t *testing.T) {
|
||||||
|
got := CapAttr(strings.Repeat("é", 400))
|
||||||
|
if n := len([]rune(got)); n != maxAttrRunes {
|
||||||
|
t.Errorf("expected %d runes, got %d", maxAttrRunes, n)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(got, "…") {
|
||||||
|
t.Error("expected an ellipsis suffix on a truncated value")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapEnum(t *testing.T) {
|
||||||
|
allowed := []string{"document", "stylesheet", "script"}
|
||||||
|
|
||||||
|
if got := MapEnum("script", allowed...); got != "script" {
|
||||||
|
t.Errorf("expected member passthrough, got %q", got)
|
||||||
|
}
|
||||||
|
if got := MapEnum("websocket", allowed...); got != "other" {
|
||||||
|
t.Errorf("expected non-member to map to other, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
50
pkg/gotenberg/buildversions.go
Normal file
50
pkg/gotenberg/buildversions.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package gotenberg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildVersionsDirPathEnvVar names the environment variable holding the
|
||||||
|
// absolute path to a directory of build-time version files. The Gotenberg image
|
||||||
|
// writes one file per module there, named by module ID and holding the version
|
||||||
|
// string of that module's backing binary, captured right after the binary is
|
||||||
|
// installed. The running process reads these files instead of executing the
|
||||||
|
// binaries, which keeps startup and the first request cheap.
|
||||||
|
const BuildVersionsDirPathEnvVar = "GOTENBERG_VERSIONS_DIR_PATH"
|
||||||
|
|
||||||
|
// BuildVersion returns the build-time version captured for the module with the
|
||||||
|
// given ID. The boolean is false when no version was captured, which is the
|
||||||
|
// case for local or non-Docker builds where the directory is absent. A module
|
||||||
|
// uses it to avoid spawning its backing binary just to report a version.
|
||||||
|
//
|
||||||
|
// It is defensive: an unset variable, a missing or unreadable file, or an empty
|
||||||
|
// value all yield ("", false), so the caller falls back to detecting the
|
||||||
|
// version at runtime. See [BuildVersionsDirPathEnvVar].
|
||||||
|
func BuildVersion(moduleID string) (string, bool) {
|
||||||
|
dir := os.Getenv(BuildVersionsDirPathEnvVar)
|
||||||
|
if dir == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module IDs are fixed internal constants, never paths. Guard anyway so a
|
||||||
|
// stray separator can't escape the versions directory.
|
||||||
|
if moduleID != filepath.Base(moduleID) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// The directory comes from a trusted operator-set environment variable,
|
||||||
|
// mirroring how engines exec their env-configured binaries.
|
||||||
|
b, err := os.ReadFile(filepath.Join(dir, moduleID)) //nolint:gosec
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
version := strings.TrimSpace(string(b))
|
||||||
|
if version == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return version, true
|
||||||
|
}
|
||||||
81
pkg/gotenberg/buildversions_test.go
Normal file
81
pkg/gotenberg/buildversions_test.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package gotenberg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildVersion(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
fileBody string
|
||||||
|
writeFile bool
|
||||||
|
setEnv bool
|
||||||
|
moduleID string
|
||||||
|
wantValue string
|
||||||
|
wantOk bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "version present",
|
||||||
|
fileBody: "Chromium 146.0",
|
||||||
|
writeFile: true,
|
||||||
|
setEnv: true,
|
||||||
|
moduleID: "chromium",
|
||||||
|
wantValue: "Chromium 146.0",
|
||||||
|
wantOk: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "value trimmed",
|
||||||
|
fileBody: " qpdf version 11.9.0 \n",
|
||||||
|
writeFile: true,
|
||||||
|
setEnv: true,
|
||||||
|
moduleID: "qpdf",
|
||||||
|
wantValue: "qpdf version 11.9.0",
|
||||||
|
wantOk: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "empty file falls back",
|
||||||
|
fileBody: " \n",
|
||||||
|
writeFile: true,
|
||||||
|
setEnv: true,
|
||||||
|
moduleID: "pdftk",
|
||||||
|
wantValue: "",
|
||||||
|
wantOk: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "missing file falls back",
|
||||||
|
writeFile: false,
|
||||||
|
setEnv: true,
|
||||||
|
moduleID: "exiftool",
|
||||||
|
wantValue: "",
|
||||||
|
wantOk: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "env unset falls back",
|
||||||
|
setEnv: false,
|
||||||
|
moduleID: "chromium",
|
||||||
|
wantValue: "",
|
||||||
|
wantOk: false,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
if tc.setEnv {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if tc.writeFile {
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, tc.moduleID), []byte(tc.fileBody), 0o600); err != nil {
|
||||||
|
t.Fatalf("write version file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Setenv(BuildVersionsDirPathEnvVar, dir)
|
||||||
|
} else {
|
||||||
|
t.Setenv(BuildVersionsDirPathEnvVar, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
value, ok := BuildVersion(tc.moduleID)
|
||||||
|
if value != tc.wantValue || ok != tc.wantOk {
|
||||||
|
t.Errorf("BuildVersion(%q) = (%q, %t), want (%q, %t)", tc.moduleID, value, ok, tc.wantValue, tc.wantOk)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,14 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
"go.opentelemetry.io/otel/codes"
|
||||||
|
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Cmd wraps an [exec.Cmd].
|
// Cmd wraps an [exec.Cmd].
|
||||||
@@ -92,11 +98,44 @@ func (cmd *Cmd) Wait() error {
|
|||||||
|
|
||||||
// Exec executes the command and waits for its completion or until the context
|
// Exec executes the command and waits for its completion or until the context
|
||||||
// is done. In any case, it kills the unix process and all its children.
|
// is done. In any case, it kills the unix process and all its children.
|
||||||
|
//
|
||||||
|
// When the context carries an active trace span, Exec records a
|
||||||
|
// "process.exec" client span around the execution. It is the single
|
||||||
|
// instrumentation point for every short-lived external binary (soffice, pdftk,
|
||||||
|
// qpdf, exiftool, pdfcpu). The span is skipped when there is no active parent,
|
||||||
|
// so process starts performed off the request path do not emit orphan roots.
|
||||||
func (cmd *Cmd) Exec() (int, error) {
|
func (cmd *Cmd) Exec() (int, error) {
|
||||||
if cmd.ctx == nil {
|
if cmd.ctx == nil {
|
||||||
return 10, errors.New("nil context")
|
return 10, errors.New("nil context")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var span trace.Span
|
||||||
|
if trace.SpanContextFromContext(cmd.ctx).IsValid() {
|
||||||
|
_, span = Tracer().Start(cmd.ctx, "process.exec",
|
||||||
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
|
trace.WithAttributes(semconv.ProcessExecutableName(filepath.Base(cmd.process.Path))),
|
||||||
|
)
|
||||||
|
defer span.End()
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := cmd.exec()
|
||||||
|
|
||||||
|
if span != nil {
|
||||||
|
span.SetAttributes(attribute.Int("process.exit.code", code))
|
||||||
|
if err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
span.SetAttributes(semconv.ErrorTypeKey.String(execErrorType(cmd.ctx)))
|
||||||
|
} else {
|
||||||
|
span.SetStatus(codes.Ok, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return code, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// exec runs the command and returns its exit code and error.
|
||||||
|
func (cmd *Cmd) exec() (int, error) {
|
||||||
err := cmd.Start()
|
err := cmd.Start()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if cmd.process.ProcessState == nil {
|
if cmd.process.ProcessState == nil {
|
||||||
@@ -138,6 +177,19 @@ func (cmd *Cmd) Exec() (int, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// execErrorType maps an execution failure to a bounded semconv error.type
|
||||||
|
// value.
|
||||||
|
func execErrorType(ctx context.Context) string {
|
||||||
|
switch {
|
||||||
|
case errors.Is(ctx.Err(), context.DeadlineExceeded):
|
||||||
|
return "context_deadline_exceeded"
|
||||||
|
case errors.Is(ctx.Err(), context.Canceled):
|
||||||
|
return "context_canceled"
|
||||||
|
default:
|
||||||
|
return "process_error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// pipeOutput creates logs entries according to the process stdout and stderr.
|
// pipeOutput creates logs entries according to the process stdout and stderr.
|
||||||
// It does nothing if the logging level is not debug.
|
// It does nothing if the logging level is not debug.
|
||||||
func (cmd *Cmd) pipeOutput() error {
|
func (cmd *Cmd) pipeOutput() error {
|
||||||
|
|||||||
136
pkg/gotenberg/cmd_test.go
Normal file
136
pkg/gotenberg/cmd_test.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package gotenberg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
"go.opentelemetry.io/otel/codes"
|
||||||
|
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||||
|
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestSpanRecorder(t *testing.T) *tracetest.SpanRecorder {
|
||||||
|
t.Helper()
|
||||||
|
recorder := tracetest.NewSpanRecorder()
|
||||||
|
provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
|
||||||
|
previous := otel.GetTracerProvider()
|
||||||
|
otel.SetTracerProvider(provider)
|
||||||
|
t.Cleanup(func() { otel.SetTracerProvider(previous) })
|
||||||
|
return recorder
|
||||||
|
}
|
||||||
|
|
||||||
|
func findSpan(recorder *tracetest.SpanRecorder, name string) sdktrace.ReadOnlySpan {
|
||||||
|
for _, s := range recorder.Ended() {
|
||||||
|
if s.Name() == name {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func spanAttr(span sdktrace.ReadOnlySpan, key string) (attribute.Value, bool) {
|
||||||
|
for _, kv := range span.Attributes() {
|
||||||
|
if string(kv.Key) == key {
|
||||||
|
return kv.Value, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return attribute.Value{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmd_Exec_NilContext(t *testing.T) {
|
||||||
|
cmd := Command(slog.New(slog.DiscardHandler), "true")
|
||||||
|
|
||||||
|
code, err := cmd.Exec()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected an error for a nil context")
|
||||||
|
}
|
||||||
|
if code != 10 {
|
||||||
|
t.Errorf("expected code 10, got %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmd_Exec_NoParentSpanProducesNoSpan(t *testing.T) {
|
||||||
|
recorder := newTestSpanRecorder(t)
|
||||||
|
|
||||||
|
cmd, err := CommandContext(context.Background(), slog.New(slog.DiscardHandler), "sh", "-c", "exit 0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create command: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := cmd.Exec()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if code != 0 {
|
||||||
|
t.Errorf("expected code 0, got %d", code)
|
||||||
|
}
|
||||||
|
if n := len(recorder.Ended()); n != 0 {
|
||||||
|
t.Errorf("expected no span without an active parent, got %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmd_Exec_RecordsSpanOnSuccess(t *testing.T) {
|
||||||
|
recorder := newTestSpanRecorder(t)
|
||||||
|
|
||||||
|
ctx, parent := otel.Tracer("test").Start(context.Background(), "parent")
|
||||||
|
cmd, err := CommandContext(ctx, slog.New(slog.DiscardHandler), "sh", "-c", "exit 0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create command: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := cmd.Exec()
|
||||||
|
parent.End()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if code != 0 {
|
||||||
|
t.Errorf("expected code 0, got %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
span := findSpan(recorder, "process.exec")
|
||||||
|
if span == nil {
|
||||||
|
t.Fatal("expected a process.exec span to be recorded")
|
||||||
|
}
|
||||||
|
if span.Status().Code != codes.Ok {
|
||||||
|
t.Errorf("expected status Ok, got %v", span.Status().Code)
|
||||||
|
}
|
||||||
|
if name, ok := spanAttr(span, "process.executable.name"); !ok || name.AsString() != "sh" {
|
||||||
|
t.Errorf("expected process.executable.name=sh, got %q (present=%t)", name.AsString(), ok)
|
||||||
|
}
|
||||||
|
if exit, ok := spanAttr(span, "process.exit.code"); !ok || exit.AsInt64() != 0 {
|
||||||
|
t.Errorf("expected process.exit.code=0, got %d (present=%t)", exit.AsInt64(), ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmd_Exec_RecordsSpanOnError(t *testing.T) {
|
||||||
|
recorder := newTestSpanRecorder(t)
|
||||||
|
|
||||||
|
ctx, parent := otel.Tracer("test").Start(context.Background(), "parent")
|
||||||
|
cmd, err := CommandContext(ctx, slog.New(slog.DiscardHandler), "sh", "-c", "exit 3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create command: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := cmd.Exec()
|
||||||
|
parent.End()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected an error for a non-zero exit code")
|
||||||
|
}
|
||||||
|
if code != 3 {
|
||||||
|
t.Errorf("expected exit code 3, got %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
span := findSpan(recorder, "process.exec")
|
||||||
|
if span == nil {
|
||||||
|
t.Fatal("expected a process.exec span to be recorded")
|
||||||
|
}
|
||||||
|
if span.Status().Code != codes.Error {
|
||||||
|
t.Errorf("expected status Error, got %v", span.Status().Code)
|
||||||
|
}
|
||||||
|
if et, ok := spanAttr(span, "error.type"); !ok || et.AsString() != "process_error" {
|
||||||
|
t.Errorf("expected error.type=process_error, got %q (present=%t)", et.AsString(), ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
52
pkg/gotenberg/errortype.go
Normal file
52
pkg/gotenberg/errortype.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package gotenberg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Engine-agnostic, low-cardinality error.type values shared by the conversion
|
||||||
|
// engines. They are safe to use both as the semconv error.type span attribute
|
||||||
|
// and as bounded metric label values.
|
||||||
|
const (
|
||||||
|
ErrorTypeTimeout = "timeout"
|
||||||
|
ErrorTypeContextCancelled = "context_cancelled"
|
||||||
|
ErrorTypeQueueSizeExceeded = "queue_size_exceeded"
|
||||||
|
ErrorTypeProcessRestarting = "process_restarting"
|
||||||
|
ErrorTypeInvalidInput = "invalid_input"
|
||||||
|
ErrorTypeUnknown = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClassifyError maps err to a bounded, engine-agnostic error.type value. It
|
||||||
|
// recognizes the failure modes shared by every engine: deadline, cancellation,
|
||||||
|
// queue saturation, and process restart. It returns an empty string for a nil
|
||||||
|
// error and [ErrorTypeUnknown] for anything it does not recognize, leaving
|
||||||
|
// engine-specific refinement (such as [ErrorTypeInvalidInput]) to the caller.
|
||||||
|
func ClassifyError(err error) string {
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return ""
|
||||||
|
case errors.Is(err, context.DeadlineExceeded):
|
||||||
|
return ErrorTypeTimeout
|
||||||
|
case errors.Is(err, context.Canceled):
|
||||||
|
return ErrorTypeContextCancelled
|
||||||
|
case errors.Is(err, ErrMaximumQueueSizeExceeded):
|
||||||
|
return ErrorTypeQueueSizeExceeded
|
||||||
|
case errors.Is(err, ErrProcessAlreadyRestarting):
|
||||||
|
return ErrorTypeProcessRestarting
|
||||||
|
default:
|
||||||
|
return ErrorTypeUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpanErrorType records errorType as the semconv error.type attribute on span.
|
||||||
|
// It is a no-op when errorType is empty.
|
||||||
|
func SpanErrorType(span trace.Span, errorType string) {
|
||||||
|
if errorType == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
span.SetAttributes(semconv.ErrorTypeKey.String(errorType))
|
||||||
|
}
|
||||||
60
pkg/gotenberg/errortype_test.go
Normal file
60
pkg/gotenberg/errortype_test.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package gotenberg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClassifyError(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"nil", nil, ""},
|
||||||
|
{"deadline", context.DeadlineExceeded, ErrorTypeTimeout},
|
||||||
|
{"canceled", context.Canceled, ErrorTypeContextCancelled},
|
||||||
|
{"queue size exceeded", ErrMaximumQueueSizeExceeded, ErrorTypeQueueSizeExceeded},
|
||||||
|
{"process restarting", ErrProcessAlreadyRestarting, ErrorTypeProcessRestarting},
|
||||||
|
{"wrapped deadline", fmt.Errorf("convert: %w", context.DeadlineExceeded), ErrorTypeTimeout},
|
||||||
|
{"joined queue", errors.Join(errors.New("attempt"), ErrMaximumQueueSizeExceeded), ErrorTypeQueueSizeExceeded},
|
||||||
|
{"arbitrary", errors.New("boom"), ErrorTypeUnknown},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := ClassifyError(tc.err); got != tc.want {
|
||||||
|
t.Errorf("ClassifyError(%v) = %q, want %q", tc.err, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpanErrorType(t *testing.T) {
|
||||||
|
recorder := newTestSpanRecorder(t)
|
||||||
|
|
||||||
|
_, span := otel.Tracer("test").Start(context.Background(), "engine.Op")
|
||||||
|
SpanErrorType(span, "") // no-op, must not add an attribute
|
||||||
|
SpanErrorType(span, ErrorTypeTimeout) // sets error.type
|
||||||
|
span.End()
|
||||||
|
|
||||||
|
got := findSpan(recorder, "engine.Op")
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("expected the span to be recorded")
|
||||||
|
}
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for _, kv := range got.Attributes() {
|
||||||
|
if string(kv.Key) == "error.type" {
|
||||||
|
count++
|
||||||
|
if kv.Value.AsString() != ErrorTypeTimeout {
|
||||||
|
t.Errorf("expected error.type=%q, got %q", ErrorTypeTimeout, kv.Value.AsString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("expected exactly one error.type attribute, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,12 @@ func (h traceContextHandler) WithGroup(name string) slog.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewStdHandler returns a [slog.Handler] instance for the standard output.
|
// NewStdHandler returns a [slog.Handler] instance for the standard output.
|
||||||
func NewStdHandler(level slog.Level, format string, fieldsPrefix string, enableGcpFields bool) (slog.Handler, error) {
|
// upperLevelCase is the value of the level-case setting that keeps the level
|
||||||
|
// field uppercase in the standard output. It mirrors gotenberg.UpperLevelCase,
|
||||||
|
// duplicated here because the internal log package cannot import gotenberg.
|
||||||
|
const upperLevelCase = "upper"
|
||||||
|
|
||||||
|
func NewStdHandler(level slog.Level, format string, fieldsPrefix string, enableGcpFields bool, levelCase string) (slog.Handler, error) {
|
||||||
// #nosec: G115
|
// #nosec: G115
|
||||||
isTerminal := term.IsTerminal(int(os.Stdout.Fd()))
|
isTerminal := term.IsTerminal(int(os.Stdout.Fd()))
|
||||||
|
|
||||||
@@ -82,7 +87,11 @@ func NewStdHandler(level slog.Level, format string, fieldsPrefix string, enableG
|
|||||||
a.Key = "severity"
|
a.Key = "severity"
|
||||||
a.Value = slog.StringValue(gcpSeverity(l))
|
a.Value = slog.StringValue(gcpSeverity(l))
|
||||||
default:
|
default:
|
||||||
a.Value = slog.StringValue(strings.ToLower(l.String()))
|
if levelCase == upperLevelCase {
|
||||||
|
a.Value = slog.StringValue(l.String())
|
||||||
|
} else {
|
||||||
|
a.Value = slog.StringValue(strings.ToLower(l.String()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
53
pkg/gotenberg/internal/log/stdhandler_test.go
Normal file
53
pkg/gotenberg/internal/log/stdhandler_test.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package log
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewStdHandler_LevelCase(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
levelCase string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"lower is the default behavior", "lower", "info"},
|
||||||
|
{"upper keeps slog casing", "upper", "INFO"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
original := os.Stderr
|
||||||
|
reader, writer, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create pipe: %v", err)
|
||||||
|
}
|
||||||
|
os.Stderr = writer
|
||||||
|
defer func() { os.Stderr = original }()
|
||||||
|
|
||||||
|
handler, err := NewStdHandler(slog.LevelInfo, "json", "", false, tc.levelCase)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create handler: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.New(handler).Info("hello")
|
||||||
|
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("close writer: %v", err)
|
||||||
|
}
|
||||||
|
out, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read output: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var record map[string]any
|
||||||
|
if err := json.Unmarshal(out, &record); err != nil {
|
||||||
|
t.Fatalf("parse log line %q: %v", out, err)
|
||||||
|
}
|
||||||
|
if record["level"] != tc.want {
|
||||||
|
t.Errorf("level = %v, want %v", record["level"], tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,5 +3,22 @@
|
|||||||
//
|
//
|
||||||
// Significantly inspired by https://github.com/lucavallin/gotel.
|
// Significantly inspired by https://github.com/lucavallin/gotel.
|
||||||
//
|
//
|
||||||
|
// # Sampling
|
||||||
|
//
|
||||||
|
// No sampler is configured in code, so the SDK default applies:
|
||||||
|
// parentbased_always_on. Every trace is recorded and exported. Operators tune
|
||||||
|
// sampling through the standard environment variables, honored by the SDK:
|
||||||
|
//
|
||||||
|
// OTEL_TRACES_SAMPLER e.g. parentbased_traceidratio, always_off
|
||||||
|
// OTEL_TRACES_SAMPLER_ARG e.g. 0.1 for a 10% ratio
|
||||||
|
//
|
||||||
|
// Head sampling drops whole traces up front, including the rare slow or failed
|
||||||
|
// conversions that matter most for diagnosis. For high-throughput deployments,
|
||||||
|
// prefer keeping head sampling permissive and applying tail sampling in the
|
||||||
|
// collector (sample on error or high latency), which decides after a trace
|
||||||
|
// completes. Gotenberg emits trace-based metric exemplars, so the conversion
|
||||||
|
// histograms still link to representative traces regardless of the head
|
||||||
|
// sampling ratio.
|
||||||
|
//
|
||||||
// See https://opentelemetry.io/.
|
// See https://opentelemetry.io/.
|
||||||
package otel
|
package otel
|
||||||
|
|||||||
@@ -14,33 +14,63 @@ import (
|
|||||||
"go.opentelemetry.io/otel/propagation"
|
"go.opentelemetry.io/otel/propagation"
|
||||||
"go.opentelemetry.io/otel/sdk/log"
|
"go.opentelemetry.io/otel/sdk/log"
|
||||||
"go.opentelemetry.io/otel/sdk/metric"
|
"go.opentelemetry.io/otel/sdk/metric"
|
||||||
|
"go.opentelemetry.io/otel/sdk/metric/exemplar"
|
||||||
"go.opentelemetry.io/otel/sdk/resource"
|
"go.opentelemetry.io/otel/sdk/resource"
|
||||||
"go.opentelemetry.io/otel/sdk/trace"
|
"go.opentelemetry.io/otel/sdk/trace"
|
||||||
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// buildResource assembles the OpenTelemetry resource shared by the tracer,
|
||||||
|
// meter, and logger providers. Detection is best-effort: a detector or merge
|
||||||
|
// failure is logged and the build proceeds with whatever was gathered, so a
|
||||||
|
// flaky environment never prevents telemetry from starting.
|
||||||
|
func buildResource(ctx context.Context, logger *slog.Logger, serviceName, serviceVersion string) *resource.Resource {
|
||||||
|
base := resource.NewWithAttributes(
|
||||||
|
semconv.SchemaURL,
|
||||||
|
semconv.ServiceName(serviceName),
|
||||||
|
semconv.ServiceVersion(serviceVersion),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Granular detectors only. The WithProcess() bundle is deliberately omitted
|
||||||
|
// because it adds process.command_args/process.command_line, which can carry
|
||||||
|
// proxy credentials and host-resolver rules passed on the command line.
|
||||||
|
detected, err := resource.New(ctx,
|
||||||
|
resource.WithFromEnv(),
|
||||||
|
resource.WithTelemetrySDK(),
|
||||||
|
resource.WithHost(),
|
||||||
|
resource.WithHostID(),
|
||||||
|
resource.WithOS(),
|
||||||
|
resource.WithContainer(),
|
||||||
|
resource.WithProcessPID(),
|
||||||
|
resource.WithProcessExecutableName(),
|
||||||
|
resource.WithProcessExecutablePath(),
|
||||||
|
resource.WithProcessRuntimeName(),
|
||||||
|
resource.WithProcessRuntimeVersion(),
|
||||||
|
resource.WithProcessRuntimeDescription(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnContext(ctx, fmt.Sprintf("partially detect OpenTelemetry resource: %s", err))
|
||||||
|
}
|
||||||
|
if detected == nil {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
merged, err := resource.Merge(detected, base)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnContext(ctx, fmt.Sprintf("merge OpenTelemetry resource: %s", err))
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
// InitTracerProvider initializes the OpenTelemetry tracer provider.
|
// InitTracerProvider initializes the OpenTelemetry tracer provider.
|
||||||
func InitTracerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, err error) {
|
func InitTracerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, err error) {
|
||||||
initOtelLogger(logger)
|
initOtelLogger(logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
hostname, err := os.Hostname()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get hostname: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := resource.Merge(
|
res := buildResource(ctx, logger, serviceName, serviceVersion)
|
||||||
resource.Default(),
|
|
||||||
resource.NewWithAttributes(
|
|
||||||
semconv.SchemaURL,
|
|
||||||
semconv.ServiceName(serviceName),
|
|
||||||
semconv.ServiceVersion(serviceVersion),
|
|
||||||
semconv.HostName(hostname),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("merge resource: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
traceOpts := []trace.TracerProviderOption{
|
traceOpts := []trace.TracerProviderOption{
|
||||||
trace.WithResource(res),
|
trace.WithResource(res),
|
||||||
@@ -71,27 +101,13 @@ func InitMeterProvider(logger *slog.Logger, serviceName, serviceVersion string)
|
|||||||
initOtelLogger(logger)
|
initOtelLogger(logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
hostname, err := os.Hostname()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get hostname: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := resource.Merge(
|
res := buildResource(ctx, logger, serviceName, serviceVersion)
|
||||||
resource.Default(),
|
|
||||||
resource.NewWithAttributes(
|
|
||||||
semconv.SchemaURL,
|
|
||||||
semconv.ServiceName(serviceName),
|
|
||||||
semconv.ServiceVersion(serviceVersion),
|
|
||||||
semconv.HostName(hostname),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("merge resource: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
metricOpts := []metric.Option{
|
metricOpts := []metric.Option{
|
||||||
metric.WithResource(res),
|
metric.WithResource(res),
|
||||||
}
|
}
|
||||||
|
metricOpts = append(metricOpts, exemplarFilterOptions()...)
|
||||||
|
|
||||||
metricReader, err := autoexport.NewMetricReader(ctx)
|
metricReader, err := autoexport.NewMetricReader(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -108,28 +124,24 @@ func InitMeterProvider(logger *slog.Logger, serviceName, serviceVersion string)
|
|||||||
return meterProvider.Shutdown, nil
|
return meterProvider.Shutdown, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// exemplarFilterOptions returns the meter provider options that pin trace-based
|
||||||
|
// exemplars, so the histograms expose the trace id of a representative
|
||||||
|
// measurement. It yields no option when the operator selects a filter via
|
||||||
|
// OTEL_METRICS_EXEMPLAR_FILTER, letting the SDK's own env handling win.
|
||||||
|
func exemplarFilterOptions() []metric.Option {
|
||||||
|
if _, ok := os.LookupEnv("OTEL_METRICS_EXEMPLAR_FILTER"); ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []metric.Option{metric.WithExemplarFilter(exemplar.TraceBasedFilter)}
|
||||||
|
}
|
||||||
|
|
||||||
// InitLoggerProvider initializes the OpenTelemetry logger provider.
|
// InitLoggerProvider initializes the OpenTelemetry logger provider.
|
||||||
func InitLoggerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, handler slog.Handler, err error) {
|
func InitLoggerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, handler slog.Handler, err error) {
|
||||||
initOtelLogger(logger)
|
initOtelLogger(logger)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
hostname, err := os.Hostname()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("get hostname: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := resource.Merge(
|
res := buildResource(ctx, logger, serviceName, serviceVersion)
|
||||||
resource.Default(),
|
|
||||||
resource.NewWithAttributes(
|
|
||||||
semconv.SchemaURL,
|
|
||||||
semconv.ServiceName(serviceName),
|
|
||||||
semconv.ServiceVersion(serviceVersion),
|
|
||||||
semconv.HostName(hostname),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("merge resource: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logOpts := []log.LoggerProviderOption{
|
logOpts := []log.LoggerProviderOption{
|
||||||
log.WithResource(res),
|
log.WithResource(res),
|
||||||
|
|||||||
151
pkg/gotenberg/internal/otel/otel_test.go
Normal file
151
pkg/gotenberg/internal/otel/otel_test.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package otel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||||
|
"go.opentelemetry.io/otel/sdk/metric/exemplar"
|
||||||
|
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||||
|
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||||
|
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildResource(t *testing.T) {
|
||||||
|
res := buildResource(context.Background(), slog.New(slog.DiscardHandler), "gotenberg", "v8.0.0")
|
||||||
|
|
||||||
|
got := map[string]struct{}{}
|
||||||
|
values := map[string]string{}
|
||||||
|
for _, kv := range res.Attributes() {
|
||||||
|
got[string(kv.Key)] = struct{}{}
|
||||||
|
values[string(kv.Key)] = kv.Value.AsString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if values[string(semconv.ServiceNameKey)] != "gotenberg" {
|
||||||
|
t.Errorf("service.name = %q, want %q", values[string(semconv.ServiceNameKey)], "gotenberg")
|
||||||
|
}
|
||||||
|
if values[string(semconv.ServiceVersionKey)] != "v8.0.0" {
|
||||||
|
t.Errorf("service.version = %q, want %q", values[string(semconv.ServiceVersionKey)], "v8.0.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range []string{
|
||||||
|
string(semconv.HostNameKey),
|
||||||
|
string(semconv.OSTypeKey),
|
||||||
|
string(semconv.ProcessRuntimeNameKey),
|
||||||
|
} {
|
||||||
|
if _, ok := got[key]; !ok {
|
||||||
|
t.Errorf("expected resource attribute %q to be present", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The command-line bundle must never be detected (it can leak credentials).
|
||||||
|
for _, key := range []string{"process.command_args", "process.command_line"} {
|
||||||
|
if _, ok := got[key]; ok {
|
||||||
|
t.Errorf("did not expect sensitive resource attribute %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInitTracerProvider_HonorsSamplerEnv guards the contract that the tracer
|
||||||
|
// provider keeps honoring OTEL_TRACES_SAMPLER. The SDK reads it only when no
|
||||||
|
// explicit sampler is configured, so any future WithSampler() would silently
|
||||||
|
// break operator-side sampling control.
|
||||||
|
func TestInitTracerProvider_HonorsSamplerEnv(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
sampler string
|
||||||
|
wantSampled bool
|
||||||
|
}{
|
||||||
|
{"always off", "always_off", false},
|
||||||
|
{"always on", "always_on", true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Setenv("OTEL_TRACES_EXPORTER", "none")
|
||||||
|
t.Setenv("OTEL_TRACES_SAMPLER", tc.sampler)
|
||||||
|
|
||||||
|
shutdown, err := InitTracerProvider(slog.New(slog.DiscardHandler), "test", "v0.0.0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("init tracer provider: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = shutdown(context.Background()) })
|
||||||
|
|
||||||
|
_, span := otel.Tracer("test").Start(context.Background(), "span")
|
||||||
|
span.End()
|
||||||
|
|
||||||
|
if got := span.SpanContext().IsSampled(); got != tc.wantSampled {
|
||||||
|
t.Errorf("OTEL_TRACES_SAMPLER=%q: IsSampled() = %v, want %v", tc.sampler, got, tc.wantSampled)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExemplarFilterOptions(t *testing.T) {
|
||||||
|
t.Run("default pins trace-based", func(t *testing.T) {
|
||||||
|
if v, ok := os.LookupEnv("OTEL_METRICS_EXEMPLAR_FILTER"); ok {
|
||||||
|
os.Unsetenv("OTEL_METRICS_EXEMPLAR_FILTER")
|
||||||
|
t.Cleanup(func() { os.Setenv("OTEL_METRICS_EXEMPLAR_FILTER", v) })
|
||||||
|
}
|
||||||
|
if got := exemplarFilterOptions(); len(got) != 1 {
|
||||||
|
t.Errorf("expected 1 option when env unset, got %d", len(got))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("env override yields no option", func(t *testing.T) {
|
||||||
|
t.Setenv("OTEL_METRICS_EXEMPLAR_FILTER", "always_off")
|
||||||
|
if got := exemplarFilterOptions(); len(got) != 0 {
|
||||||
|
t.Errorf("expected 0 options when env set, got %d", len(got))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMeterProvider_TraceBasedExemplar guards that the trace-based filter we pin
|
||||||
|
// actually attaches a trace id to a histogram measurement recorded inside a
|
||||||
|
// sampled span.
|
||||||
|
func TestMeterProvider_TraceBasedExemplar(t *testing.T) {
|
||||||
|
reader := sdkmetric.NewManualReader()
|
||||||
|
provider := sdkmetric.NewMeterProvider(
|
||||||
|
sdkmetric.WithReader(reader),
|
||||||
|
sdkmetric.WithExemplarFilter(exemplar.TraceBasedFilter),
|
||||||
|
)
|
||||||
|
t.Cleanup(func() { _ = provider.Shutdown(context.Background()) })
|
||||||
|
|
||||||
|
hist, err := provider.Meter("test").Float64Histogram("conversion.duration")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create histogram: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tracer := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.AlwaysSample())).Tracer("test")
|
||||||
|
ctx, span := tracer.Start(context.Background(), "conversion")
|
||||||
|
hist.Record(ctx, 1.0)
|
||||||
|
traceID := span.SpanContext().TraceID()
|
||||||
|
span.End()
|
||||||
|
|
||||||
|
var rm metricdata.ResourceMetrics
|
||||||
|
if err := reader.Collect(context.Background(), &rm); err != nil {
|
||||||
|
t.Fatalf("collect: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var found bool
|
||||||
|
for _, sm := range rm.ScopeMetrics {
|
||||||
|
for _, m := range sm.Metrics {
|
||||||
|
hd, ok := m.Data.(metricdata.Histogram[float64])
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, dp := range hd.DataPoints {
|
||||||
|
for _, ex := range dp.Exemplars {
|
||||||
|
if string(ex.TraceID) == string(traceID[:]) {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
t.Error("expected a trace-based exemplar carrying the span trace id")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,21 +45,23 @@ func (mod *DebuggableMock) Debug() map[string]any {
|
|||||||
//
|
//
|
||||||
//nolint:dupl
|
//nolint:dupl
|
||||||
type PdfEngineMock struct {
|
type PdfEngineMock struct {
|
||||||
MergeMock func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
|
MergeMock func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
|
||||||
SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
|
SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
|
||||||
FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath string) error
|
FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath string) error
|
||||||
ConvertMock func(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
|
ConvertMock func(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
|
||||||
ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
|
ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
|
||||||
PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
|
PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
|
||||||
WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
|
WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
|
||||||
ReadBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error)
|
ReadBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error)
|
||||||
EncryptMock func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
|
EncryptMock func(ctx context.Context, logger *slog.Logger, inputPath string, opts EncryptOptions) error
|
||||||
EmbedFilesMock func(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
|
EmbedFilesMock func(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
|
||||||
EmbedFilesMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error
|
EmbedFilesMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error
|
||||||
WriteBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
|
WriteBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
|
||||||
WatermarkMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
|
WatermarkMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
|
||||||
StampMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
|
StampMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
|
||||||
RotateMock func(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
|
RotateMock func(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
|
||||||
|
InjectFacturXXMPMock func(ctx context.Context, logger *slog.Logger, facturX FacturX, inputPath string) error
|
||||||
|
ReadPdfAConformanceMock func(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (engine *PdfEngineMock) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
func (engine *PdfEngineMock) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
||||||
@@ -94,8 +96,8 @@ func (engine *PdfEngineMock) ReadBookmarks(ctx context.Context, logger *slog.Log
|
|||||||
return engine.ReadBookmarksMock(ctx, logger, inputPath)
|
return engine.ReadBookmarksMock(ctx, logger, inputPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
|
func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts EncryptOptions) error {
|
||||||
return engine.EncryptMock(ctx, logger, inputPath, userPassword, ownerPassword)
|
return engine.EncryptMock(ctx, logger, inputPath, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
||||||
@@ -122,6 +124,14 @@ func (engine *PdfEngineMock) Rotate(ctx context.Context, logger *slog.Logger, in
|
|||||||
return engine.RotateMock(ctx, logger, inputPath, angle, pages)
|
return engine.RotateMock(ctx, logger, inputPath, angle, pages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (engine *PdfEngineMock) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX FacturX, inputPath string) error {
|
||||||
|
return engine.InjectFacturXXMPMock(ctx, logger, facturX, inputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (engine *PdfEngineMock) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
|
||||||
|
return engine.ReadPdfAConformanceMock(ctx, logger, inputPath)
|
||||||
|
}
|
||||||
|
|
||||||
// PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface.
|
// PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface.
|
||||||
type PdfEngineProviderMock struct {
|
type PdfEngineProviderMock struct {
|
||||||
PdfEngineMock func() (PdfEngine, error)
|
PdfEngineMock func() (PdfEngine, error)
|
||||||
@@ -152,13 +162,14 @@ func (p *ProcessMock) Healthy(logger *slog.Logger) bool {
|
|||||||
|
|
||||||
// ProcessSupervisorMock is a mock for the [ProcessSupervisor] interface.
|
// ProcessSupervisorMock is a mock for the [ProcessSupervisor] interface.
|
||||||
type ProcessSupervisorMock struct {
|
type ProcessSupervisorMock struct {
|
||||||
LaunchMock func() error
|
LaunchMock func() error
|
||||||
ShutdownMock func() error
|
ShutdownMock func() error
|
||||||
HealthyMock func() bool
|
HealthyMock func() bool
|
||||||
RunMock func(ctx context.Context, logger *slog.Logger, task func() error) error
|
RunMock func(ctx context.Context, logger *slog.Logger, task func() error) error
|
||||||
ReqQueueSizeMock func() int64
|
ReqQueueSizeMock func() int64
|
||||||
RestartsCountMock func() int64
|
RestartsCountMock func() int64
|
||||||
ActiveTasksCountMock func() int64
|
ActiveTasksCountMock func() int64
|
||||||
|
ConversionsSinceRestartMock func() int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ProcessSupervisorMock) Launch() error {
|
func (s *ProcessSupervisorMock) Launch() error {
|
||||||
@@ -189,6 +200,10 @@ func (s *ProcessSupervisorMock) ActiveTasksCount() int64 {
|
|||||||
return s.ActiveTasksCountMock()
|
return s.ActiveTasksCountMock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ProcessSupervisorMock) ConversionsSinceRestart() int64 {
|
||||||
|
return s.ConversionsSinceRestartMock()
|
||||||
|
}
|
||||||
|
|
||||||
// MetricsProviderMock is a mock for the [MetricsProvider] interface.
|
// MetricsProviderMock is a mock for the [MetricsProvider] interface.
|
||||||
type MetricsProviderMock struct {
|
type MetricsProviderMock struct {
|
||||||
MetricsMock func() ([]Metric, error)
|
MetricsMock func() ([]Metric, error)
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package gotenberg
|
package gotenberg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
@@ -12,6 +15,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dlclark/regexp2"
|
"github.com/dlclark/regexp2"
|
||||||
|
"golang.org/x/net/http/httpproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrNonPublicIP indicates that an outbound URL targets an IP address that
|
// ErrNonPublicIP indicates that an outbound URL targets an IP address that
|
||||||
@@ -45,11 +49,45 @@ var outboundDialer = &net.Dialer{
|
|||||||
KeepAlive: 30 * time.Second,
|
KeepAlive: 30 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nonPublicIPv6Prefixes lists IPv6 ranges that the standard library does
|
||||||
|
// not classify via [netip.Addr] helpers but that must not be considered
|
||||||
|
// public:
|
||||||
|
//
|
||||||
|
// - 2002::/16 6to4 (RFC 3056, deprecated by RFC 7526). Bits 16-47
|
||||||
|
// embed an IPv4 destination, including private ones.
|
||||||
|
// - 2001::/32 Teredo (RFC 4380). Bits 96-127 embed an IPv4
|
||||||
|
// destination, including private ones.
|
||||||
|
// - 64:ff9b::/96 NAT64 well-known prefix (RFC 6052). Low 32 bits
|
||||||
|
// embed an IPv4 destination translated by a NAT64 gateway.
|
||||||
|
// - 64:ff9b:1::/48 NAT64 local-use prefix (RFC 8215). Same risk.
|
||||||
|
// - fec0::/10 Deprecated site-local (RFC 3879). Not covered by
|
||||||
|
// [netip.Addr.IsPrivate] which only handles fc00::/7.
|
||||||
|
// - ::/96 IPv4-compatible IPv6 (deprecated). Embeds an IPv4
|
||||||
|
// destination and is not handled by [netip.Addr.Unmap].
|
||||||
|
// - 2001:db8::/32 Documentation range (RFC 3849). Never routable.
|
||||||
|
// - 100::/64 Discard prefix (RFC 6666).
|
||||||
|
var nonPublicIPv6Prefixes = []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("2002::/16"),
|
||||||
|
netip.MustParsePrefix("2001::/32"),
|
||||||
|
netip.MustParsePrefix("64:ff9b::/96"),
|
||||||
|
netip.MustParsePrefix("64:ff9b:1::/48"),
|
||||||
|
netip.MustParsePrefix("fec0::/10"),
|
||||||
|
netip.MustParsePrefix("::/96"),
|
||||||
|
netip.MustParsePrefix("2001:db8::/32"),
|
||||||
|
netip.MustParsePrefix("100::/64"),
|
||||||
|
}
|
||||||
|
|
||||||
// IsPublicIP reports whether addr is reachable on the public internet. It
|
// IsPublicIP reports whether addr is reachable on the public internet. It
|
||||||
// returns false for loopback, private (RFC1918), link-local, unspecified,
|
// returns false for loopback, private (RFC1918), link-local, unspecified,
|
||||||
// multicast, and unique-local addresses. IPv4-mapped IPv6 addresses are
|
// multicast, and unique-local addresses. IPv4-mapped IPv6 addresses are
|
||||||
// unmapped before evaluation so that [::ffff:127.0.0.1] is correctly
|
// unmapped before evaluation so that [::ffff:127.0.0.1] is correctly
|
||||||
// identified as loopback.
|
// identified as loopback.
|
||||||
|
//
|
||||||
|
// IPv6 prefixes that tunnel or translate to an embedded IPv4 destination
|
||||||
|
// (6to4, Teredo, NAT64) are rejected wholesale rather than recursed into,
|
||||||
|
// because a host that routes them implicitly trusts the IPv4 mapping and
|
||||||
|
// the prefixes themselves are deprecated or translation-only. See
|
||||||
|
// [nonPublicIPv6Prefixes] for the full list and rationale.
|
||||||
func IsPublicIP(addr netip.Addr) bool {
|
func IsPublicIP(addr netip.Addr) bool {
|
||||||
if !addr.IsValid() {
|
if !addr.IsValid() {
|
||||||
return false
|
return false
|
||||||
@@ -65,6 +103,13 @@ func IsPublicIP(addr netip.Addr) bool {
|
|||||||
addr.IsInterfaceLocalMulticast():
|
addr.IsInterfaceLocalMulticast():
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if addr.Is6() {
|
||||||
|
for _, p := range nonPublicIPv6Prefixes {
|
||||||
|
if p.Contains(addr) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,9 +393,37 @@ func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, er
|
|||||||
//
|
//
|
||||||
// The default posture is permissive; callers pass [WithDenyPrivateIPs]
|
// The default posture is permissive; callers pass [WithDenyPrivateIPs]
|
||||||
// or [WithDenyPublicIPs] to opt into IP-class rejection.
|
// 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 := 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{
|
return &http.Client{
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
Transport: &outboundRoundTripper{
|
Transport: &outboundRoundTripper{
|
||||||
@@ -411,3 +484,99 @@ func DialPinned(ctx context.Context, network string, addrs []netip.Addr, port st
|
|||||||
}
|
}
|
||||||
return nil, lastErr
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,38 @@ func TestIsPublicIP(t *testing.T) {
|
|||||||
// Multicast.
|
// Multicast.
|
||||||
{"224.0.0.1", false},
|
{"224.0.0.1", false},
|
||||||
{"ff02::1", false},
|
{"ff02::1", false},
|
||||||
|
|
||||||
|
// 6to4 wrapping internal/private IPv4 (RFC 3056, deprecated by
|
||||||
|
// RFC 7526). a9fe:a9fe = 169.254.169.254 (cloud metadata).
|
||||||
|
{"2002:a9fe:a9fe::", false},
|
||||||
|
{"2002:0a00:0001::", false},
|
||||||
|
{"2002:c0a8:0101::", false},
|
||||||
|
|
||||||
|
// 6to4 wrapping a public IPv4 (8.8.8.8) is rejected wholesale.
|
||||||
|
{"2002:0808:0808::", false},
|
||||||
|
|
||||||
|
// NAT64 well-known prefix (RFC 6052).
|
||||||
|
{"64:ff9b::a9fe:a9fe", false},
|
||||||
|
{"64:ff9b::0808:0808", false},
|
||||||
|
|
||||||
|
// NAT64 local-use prefix (RFC 8215).
|
||||||
|
{"64:ff9b:1::a9fe:a9fe", false},
|
||||||
|
|
||||||
|
// Teredo (RFC 4380).
|
||||||
|
{"2001:0:abcd:ef12:3456:7890:a9fe:a9fe", false},
|
||||||
|
|
||||||
|
// Deprecated site-local (RFC 3879).
|
||||||
|
{"fec0::1", false},
|
||||||
|
{"feff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", false},
|
||||||
|
|
||||||
|
// IPv4-compatible IPv6 (deprecated, not handled by Unmap).
|
||||||
|
{"::a9fe:a9fe", false},
|
||||||
|
|
||||||
|
// Documentation prefix (RFC 3849).
|
||||||
|
{"2001:db8::1", false},
|
||||||
|
|
||||||
|
// Discard prefix (RFC 6666).
|
||||||
|
{"100::1", false},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.addr, func(t *testing.T) {
|
t.Run(tc.addr, func(t *testing.T) {
|
||||||
addr, err := netip.ParseAddr(tc.addr)
|
addr, err := netip.ParseAddr(tc.addr)
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ var (
|
|||||||
// ErrPdfRotateAngleNotSupported is returned when the rotation angle is
|
// ErrPdfRotateAngleNotSupported is returned when the rotation angle is
|
||||||
// not supported.
|
// not supported.
|
||||||
ErrPdfRotateAngleNotSupported = errors.New("rotation angle not supported")
|
ErrPdfRotateAngleNotSupported = errors.New("rotation angle not supported")
|
||||||
|
|
||||||
|
// ErrPdfFacturXValueNotSupported is returned when a Factur-X field value
|
||||||
|
// (e.g., an unknown conformance level or document type) is not supported.
|
||||||
|
ErrPdfFacturXValueNotSupported = errors.New("Factur-X value not supported")
|
||||||
)
|
)
|
||||||
|
|
||||||
// PdfEngineInvalidArgsError represents an error returned by a PDF engine when
|
// PdfEngineInvalidArgsError represents an error returned by a PDF engine when
|
||||||
@@ -143,6 +147,56 @@ type PdfFormats struct {
|
|||||||
PdfUa bool
|
PdfUa bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PdfPermissions gathers the document permissions enforced when a PDF is
|
||||||
|
// encrypted. Each field defaults to true (the action is allowed); set a field
|
||||||
|
// to false to restrict it. Restrictions are advisory: viewers honor them, but
|
||||||
|
// they are not cryptographically enforced once the document opens.
|
||||||
|
type PdfPermissions struct {
|
||||||
|
// AllowPrinting permits printing the document.
|
||||||
|
AllowPrinting bool
|
||||||
|
|
||||||
|
// AllowCopying permits extracting text and graphics.
|
||||||
|
AllowCopying bool
|
||||||
|
|
||||||
|
// AllowModifying permits changing the document content.
|
||||||
|
AllowModifying bool
|
||||||
|
|
||||||
|
// AllowAnnotating permits adding or modifying annotations.
|
||||||
|
AllowAnnotating bool
|
||||||
|
|
||||||
|
// AllowFillingForms permits filling in form fields.
|
||||||
|
AllowFillingForms bool
|
||||||
|
|
||||||
|
// AllowAssembling permits inserting, deleting, and rotating pages.
|
||||||
|
AllowAssembling bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restricted reports whether at least one permission is denied.
|
||||||
|
func (p PdfPermissions) Restricted() bool {
|
||||||
|
return !p.AllowPrinting ||
|
||||||
|
!p.AllowCopying ||
|
||||||
|
!p.AllowModifying ||
|
||||||
|
!p.AllowAnnotating ||
|
||||||
|
!p.AllowFillingForms ||
|
||||||
|
!p.AllowAssembling
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptOptions gathers the parameters for encrypting a PDF. An empty
|
||||||
|
// UserPassword with a set OwnerPassword produces an owner-only document: it
|
||||||
|
// opens without a password but enforces the [PdfPermissions].
|
||||||
|
type EncryptOptions struct {
|
||||||
|
// UserPassword is required to open the document. Empty means no open
|
||||||
|
// password.
|
||||||
|
UserPassword string
|
||||||
|
|
||||||
|
// OwnerPassword grants full access (lifts the permission restrictions).
|
||||||
|
// When empty, it defaults to UserPassword.
|
||||||
|
OwnerPassword string
|
||||||
|
|
||||||
|
// Permissions are the actions allowed when opened with the user password.
|
||||||
|
Permissions PdfPermissions
|
||||||
|
}
|
||||||
|
|
||||||
// Bookmark represents a node in the PDF document's outline
|
// Bookmark represents a node in the PDF document's outline
|
||||||
// (table of contents).
|
// (table of contents).
|
||||||
type Bookmark struct {
|
type Bookmark struct {
|
||||||
@@ -151,6 +205,59 @@ type Bookmark struct {
|
|||||||
Children []Bookmark `json:"children,omitempty"`
|
Children []Bookmark `json:"children,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// FacturXConformanceMinimum represents the MINIMUM Factur-X conformance level.
|
||||||
|
FacturXConformanceMinimum string = "MINIMUM"
|
||||||
|
|
||||||
|
// FacturXConformanceBasicWL represents the BASIC WL Factur-X conformance level.
|
||||||
|
FacturXConformanceBasicWL string = "BASIC WL"
|
||||||
|
|
||||||
|
// FacturXConformanceBasic represents the BASIC Factur-X conformance level.
|
||||||
|
FacturXConformanceBasic string = "BASIC"
|
||||||
|
|
||||||
|
// FacturXConformanceEN16931 represents the EN 16931 Factur-X conformance level.
|
||||||
|
FacturXConformanceEN16931 string = "EN 16931"
|
||||||
|
|
||||||
|
// FacturXConformanceExtended represents the EXTENDED Factur-X conformance level.
|
||||||
|
FacturXConformanceExtended string = "EXTENDED"
|
||||||
|
|
||||||
|
// FacturXConformanceXRechnung represents the XRECHNUNG Factur-X conformance level.
|
||||||
|
FacturXConformanceXRechnung string = "XRECHNUNG"
|
||||||
|
|
||||||
|
// FacturXDocumentTypeInvoice represents the INVOICE Factur-X document type.
|
||||||
|
FacturXDocumentTypeInvoice string = "INVOICE"
|
||||||
|
|
||||||
|
// FacturXDocumentTypeOrder represents the ORDER Factur-X document type.
|
||||||
|
FacturXDocumentTypeOrder string = "ORDER"
|
||||||
|
|
||||||
|
// FacturXDocumentTypeOrderResponse represents the ORDER_RESPONSE Factur-X document type.
|
||||||
|
FacturXDocumentTypeOrderResponse string = "ORDER_RESPONSE"
|
||||||
|
|
||||||
|
// FacturXDocumentTypeOrderChange represents the ORDER_CHANGE Factur-X document type.
|
||||||
|
FacturXDocumentTypeOrderChange string = "ORDER_CHANGE"
|
||||||
|
|
||||||
|
// FacturXDocumentFileName is the canonical name of the embedded XML invoice
|
||||||
|
// mandated by the Factur-X standard. Validators expect this exact name.
|
||||||
|
FacturXDocumentFileName string = "factur-x.xml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FacturX gathers the properties required by the Factur-X/ZUGFeRD standard for
|
||||||
|
// the document-level XMP metadata packet of a PDF/A-3.
|
||||||
|
type FacturX struct {
|
||||||
|
// ConformanceLevel is one of the FacturXConformance* values.
|
||||||
|
ConformanceLevel string
|
||||||
|
|
||||||
|
// DocumentType is one of the FacturXDocumentType* values.
|
||||||
|
DocumentType string
|
||||||
|
|
||||||
|
// DocumentFileName is the name of the embedded XML invoice. It is set
|
||||||
|
// internally to the canonical [FacturXDocumentFileName], not by the caller.
|
||||||
|
DocumentFileName string
|
||||||
|
|
||||||
|
// Version is the Factur-X version (e.g., "1.0").
|
||||||
|
Version string
|
||||||
|
}
|
||||||
|
|
||||||
// PdfEngine provides an interface for operations on PDFs. Implementations
|
// PdfEngine provides an interface for operations on PDFs. Implementations
|
||||||
// can use various tools like PDFtk, or implement functionality directly in
|
// can use various tools like PDFtk, or implement functionality directly in
|
||||||
// Go.
|
// Go.
|
||||||
@@ -190,11 +297,11 @@ type PdfEngine interface {
|
|||||||
// The bookmarks parameter represents the hierarchical tree of the outline.
|
// The bookmarks parameter represents the hierarchical tree of the outline.
|
||||||
WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
|
WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
|
||||||
|
|
||||||
// Encrypt adds password protection to a PDF file.
|
// Encrypt adds password protection and permission restrictions to a PDF
|
||||||
// The userPassword is required to open the document.
|
// file, as described by [EncryptOptions]. An empty user password with a set
|
||||||
// The ownerPassword provides full access to the document.
|
// owner password yields an owner-only document (opens without a password,
|
||||||
// If the ownerPassword is empty, it defaults to the userPassword.
|
// permissions enforced).
|
||||||
Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
|
Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts EncryptOptions) error
|
||||||
|
|
||||||
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
|
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
|
||||||
// without modifying the main PDF content.
|
// without modifying the main PDF content.
|
||||||
@@ -216,6 +323,18 @@ type PdfEngine interface {
|
|||||||
// Rotate rotates pages of a PDF file by the given angle (90, 180, 270).
|
// Rotate rotates pages of a PDF file by the given angle (90, 180, 270).
|
||||||
// If pages is empty, all pages are rotated.
|
// If pages is empty, all pages are rotated.
|
||||||
Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
|
Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
|
||||||
|
|
||||||
|
// InjectFacturXXMP injects Factur-X/ZUGFeRD XMP metadata into the
|
||||||
|
// document-level XMP packet (Catalog /Metadata stream) of a PDF/A-3. It
|
||||||
|
// registers the fx namespace, the four fx properties, and the matching
|
||||||
|
// PDF/A extension schema so the result stays PDF/A-valid.
|
||||||
|
InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX FacturX, inputPath string) error
|
||||||
|
|
||||||
|
// ReadPdfAConformance reads the PDF/A part and conformance (e.g., "3" and
|
||||||
|
// "B") from the document-level XMP packet (Catalog /Metadata stream,
|
||||||
|
// pdfaid:part and pdfaid:conformance). It returns empty strings when the
|
||||||
|
// document carries no PDF/A identification.
|
||||||
|
ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (part string, conformance string, err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PdfEngineProvider offers an interface to instantiate a [PdfEngine].
|
// PdfEngineProvider offers an interface to instantiate a [PdfEngine].
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
"go.opentelemetry.io/otel/codes"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrProcessAlreadyRestarting happens if the [ProcessSupervisor] is trying
|
// ErrProcessAlreadyRestarting happens if the [ProcessSupervisor] is trying
|
||||||
@@ -76,10 +80,32 @@ type ProcessSupervisor interface {
|
|||||||
|
|
||||||
// ActiveTasksCount returns the current number of active tasks.
|
// ActiveTasksCount returns the current number of active tasks.
|
||||||
ActiveTasksCount() int64
|
ActiveTasksCount() int64
|
||||||
|
|
||||||
|
// ConversionsSinceRestart returns the number of tasks handled since the
|
||||||
|
// last process (re)start.
|
||||||
|
ConversionsSinceRestart() int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// healthCheckCacheTTL caches successful health probe results so kubelet-
|
||||||
|
// style probes (liveness + readiness, every few seconds each) do not
|
||||||
|
// hammer the underlying process with CDP roundtrips on every call.
|
||||||
|
// Tuned to bridge typical probe periods while still catching outages
|
||||||
|
// quickly: a real outage surfaces on the next probe after the TTL
|
||||||
|
// elapses.
|
||||||
|
const healthCheckCacheTTL = 2 * time.Second
|
||||||
|
|
||||||
|
// healthFailureThreshold is the number of consecutive Healthy() failures
|
||||||
|
// the supervisor tolerates before reporting unhealthy. Absorbs single-
|
||||||
|
// probe blips of transient CDP latency (for example a slow
|
||||||
|
// Browser.getVersion roundtrip when several conversion slots are
|
||||||
|
// simultaneously stuck), without delaying detection of a real outage.
|
||||||
|
// The container orchestrator's own failureThreshold stacks on top of
|
||||||
|
// this. See https://github.com/gotenberg/gotenberg/issues/1561.
|
||||||
|
const healthFailureThreshold = 2
|
||||||
|
|
||||||
type processSupervisor struct {
|
type processSupervisor struct {
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
engine string
|
||||||
process Process
|
process Process
|
||||||
maxReqLimit int64
|
maxReqLimit int64
|
||||||
maxQueueSize int64
|
maxQueueSize int64
|
||||||
@@ -100,19 +126,32 @@ type processSupervisor struct {
|
|||||||
activeTasks atomic.Int64
|
activeTasks atomic.Int64
|
||||||
restartMutex sync.Mutex
|
restartMutex sync.Mutex
|
||||||
idleShutdownTimeout time.Duration
|
idleShutdownTimeout time.Duration
|
||||||
lastActivity atomic.Int64 // unix nano timestamp of last completed task
|
lastActivity atomic.Int64 // unix nano timestamp of last completed task
|
||||||
idleMu sync.Mutex // protects idleStopChan
|
// healthMu serializes Healthy() probes so concurrent callers do not
|
||||||
idleStopChan chan struct{} // signal to stop the idle ticker goroutine
|
// all issue a CDP roundtrip; the second caller hits the refreshed
|
||||||
|
// cache instead.
|
||||||
|
healthMu sync.Mutex
|
||||||
|
lastHealthyAt atomic.Int64 // unix nano of last successful probe; 0 means never
|
||||||
|
consecutiveHealthFailures atomic.Int64 // reset to 0 on every successful probe
|
||||||
|
idleMu sync.Mutex // protects idleStopChan
|
||||||
|
idleStopChan chan struct{} // signal to stop the idle ticker goroutine
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProcessSupervisor initializes a new [ProcessSupervisor].
|
// NewProcessSupervisor initializes a new [ProcessSupervisor]. engine names the
|
||||||
func NewProcessSupervisor(logger *slog.Logger, process Process, maxReqLimit, maxQueueSize, maxConcurrency int64, idleShutdownTimeout time.Duration) ProcessSupervisor {
|
// managed process (for example "chromium" or "libreoffice") and prefixes the
|
||||||
|
// telemetry sub-spans; an empty engine falls back to "process".
|
||||||
|
func NewProcessSupervisor(logger *slog.Logger, engine string, process Process, maxReqLimit, maxQueueSize, maxConcurrency int64, idleShutdownTimeout time.Duration) ProcessSupervisor {
|
||||||
if maxConcurrency < 1 {
|
if maxConcurrency < 1 {
|
||||||
maxConcurrency = 1
|
maxConcurrency = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if engine == "" {
|
||||||
|
engine = "process"
|
||||||
|
}
|
||||||
|
|
||||||
b := &processSupervisor{
|
b := &processSupervisor{
|
||||||
logger: logger,
|
logger: logger,
|
||||||
|
engine: engine,
|
||||||
process: process,
|
process: process,
|
||||||
semaphore: make(chan struct{}, maxConcurrency),
|
semaphore: make(chan struct{}, maxConcurrency),
|
||||||
maxReqLimit: maxReqLimit,
|
maxReqLimit: maxReqLimit,
|
||||||
@@ -195,15 +234,69 @@ func (s *processSupervisor) Healthy() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.isRestarting.Load() {
|
if s.isRestarting.Load() {
|
||||||
// A restarting process is not yet healthy — this gives load balancers
|
// A restarting process is not yet healthy. This gives load balancers
|
||||||
// honest information so they can avoid routing traffic to this node.
|
// honest information so they can avoid routing traffic to this node.
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.process.Healthy(s.logger)
|
// Cache hit: a recent probe succeeded. Skip the CDP roundtrip so probe
|
||||||
|
// spam does not pile commands onto a busy websocket.
|
||||||
|
if s.recentlyHealthy() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize probes so concurrent callers do not all roundtrip. The
|
||||||
|
// second caller will see the refreshed cache (or counter) and return
|
||||||
|
// without re-probing.
|
||||||
|
s.healthMu.Lock()
|
||||||
|
defer s.healthMu.Unlock()
|
||||||
|
|
||||||
|
if s.recentlyHealthy() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.process.Healthy(s.logger) {
|
||||||
|
s.lastHealthyAt.Store(time.Now().UnixNano())
|
||||||
|
s.consecutiveHealthFailures.Store(0)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.consecutiveHealthFailures.Add(1) < healthFailureThreshold {
|
||||||
|
// First failure: tolerate it. Under load, a single blown CDP
|
||||||
|
// timeout is more likely transient pressure than a dead process.
|
||||||
|
// A genuinely dead process will fail the next probe as well and
|
||||||
|
// flip us unhealthy then.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// recentlyHealthy reports whether a successful probe landed within
|
||||||
|
// [healthCheckCacheTTL]. Negative results are never cached so recovery
|
||||||
|
// from a real outage is observable on the very next probe.
|
||||||
|
func (s *processSupervisor) recentlyHealthy() bool {
|
||||||
|
last := s.lastHealthyAt.Load()
|
||||||
|
if last == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return time.Since(time.Unix(0, last)) < healthCheckCacheTTL
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *processSupervisor) Run(ctx context.Context, logger *slog.Logger, task func() error) error {
|
func (s *processSupervisor) Run(ctx context.Context, logger *slog.Logger, task func() error) error {
|
||||||
|
// Time spent before the task body runs: queueing, slot acquisition, lazy
|
||||||
|
// launch, and health checks. Ended once, when the task is about to execute.
|
||||||
|
_, queueSpan := Tracer().Start(ctx, s.engine+".queue.wait",
|
||||||
|
trace.WithSpanKind(trace.SpanKindInternal),
|
||||||
|
)
|
||||||
|
queueWaitDone := false
|
||||||
|
endQueueWait := func() {
|
||||||
|
if !queueWaitDone {
|
||||||
|
queueWaitDone = true
|
||||||
|
queueSpan.End()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer endQueueWait()
|
||||||
|
|
||||||
// Atomically check and increment the queue size to avoid the TOCTOU race
|
// Atomically check and increment the queue size to avoid the TOCTOU race
|
||||||
// originally reported in https://github.com/gotenberg/gotenberg/issues/951.
|
// originally reported in https://github.com/gotenberg/gotenberg/issues/951.
|
||||||
for {
|
for {
|
||||||
@@ -252,6 +345,7 @@ func (s *processSupervisor) Run(ctx context.Context, logger *slog.Logger, task f
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
endQueueWait()
|
||||||
err := s.runWithDeadline(ctx, task)
|
err := s.runWithDeadline(ctx, task)
|
||||||
|
|
||||||
if s.maybeRestartAfterTask(logger) {
|
if s.maybeRestartAfterTask(logger) {
|
||||||
@@ -390,8 +484,8 @@ func (s *processSupervisor) ensureStarted(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err := s.runWithDeadline(ctx, func() error {
|
err := s.tracedLaunch(ctx, "first_start", func() error {
|
||||||
return s.Launch()
|
return s.runWithDeadline(ctx, s.Launch)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("process first start: %w", err)
|
return fmt.Errorf("process first start: %w", err)
|
||||||
@@ -400,6 +494,27 @@ func (s *processSupervisor) ensureStarted(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tracedLaunch wraps a process (re)start in an <engine>.process.start span,
|
||||||
|
// tagged with the reason that triggered it. The eager restart after the maximum
|
||||||
|
// request limit runs on a background context, so its span is a detached root.
|
||||||
|
func (s *processSupervisor) tracedLaunch(ctx context.Context, reason string, launch func() error) error {
|
||||||
|
_, span := Tracer().Start(ctx, s.engine+".process.start",
|
||||||
|
trace.WithSpanKind(trace.SpanKindInternal),
|
||||||
|
trace.WithAttributes(attribute.String("gotenberg.process.start.reason", reason)),
|
||||||
|
)
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
err := launch()
|
||||||
|
if err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
span.SetStatus(codes.Ok, "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ensureHealthy checks the underlying process health and triggers a
|
// ensureHealthy checks the underlying process health and triggers a
|
||||||
// synchronous restart if the process is unhealthy. Skips the check if a
|
// synchronous restart if the process is unhealthy. Skips the check if a
|
||||||
// restart is already in progress.
|
// restart is already in progress.
|
||||||
@@ -410,7 +525,7 @@ func (s *processSupervisor) ensureHealthy(ctx context.Context) error {
|
|||||||
|
|
||||||
s.logger.DebugContext(context.Background(), "process is unhealthy, cannot handle task, restarting...")
|
s.logger.DebugContext(context.Background(), "process is unhealthy, cannot handle task, restarting...")
|
||||||
|
|
||||||
if err := s.doRestart(ctx); err != nil {
|
if err := s.doRestart(ctx, "unhealthy"); err != nil {
|
||||||
return fmt.Errorf("process restart before task: %w", err)
|
return fmt.Errorf("process restart before task: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,7 +548,7 @@ func (s *processSupervisor) maybeRestartAfterTask(logger *slog.Logger) bool {
|
|||||||
s.logger.DebugContext(context.Background(), "max request limit reached, restarting eagerly...")
|
s.logger.DebugContext(context.Background(), "max request limit reached, restarting eagerly...")
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
restartErr := s.doRestartLocked(context.Background())
|
restartErr := s.doRestartLocked(context.Background(), "max_requests")
|
||||||
s.restartMutex.Unlock()
|
s.restartMutex.Unlock()
|
||||||
if restartErr != nil {
|
if restartErr != nil {
|
||||||
s.logger.ErrorContext(context.Background(), fmt.Sprintf("process restart after task: %v", restartErr))
|
s.logger.ErrorContext(context.Background(), fmt.Sprintf("process restart after task: %v", restartErr))
|
||||||
@@ -447,15 +562,15 @@ func (s *processSupervisor) maybeRestartAfterTask(logger *slog.Logger) bool {
|
|||||||
|
|
||||||
// doRestart coordinates a process restart, draining all active concurrent
|
// doRestart coordinates a process restart, draining all active concurrent
|
||||||
// tasks before stopping and restarting the process.
|
// tasks before stopping and restarting the process.
|
||||||
func (s *processSupervisor) doRestart(ctx context.Context) error {
|
func (s *processSupervisor) doRestart(ctx context.Context, reason string) error {
|
||||||
s.restartMutex.Lock()
|
s.restartMutex.Lock()
|
||||||
defer s.restartMutex.Unlock()
|
defer s.restartMutex.Unlock()
|
||||||
|
|
||||||
return s.doRestartLocked(ctx)
|
return s.doRestartLocked(ctx, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
// doRestartLocked performs the restart drain logic. The caller must hold restartMutex.
|
// doRestartLocked performs the restart drain logic. The caller must hold restartMutex.
|
||||||
func (s *processSupervisor) doRestartLocked(ctx context.Context) error {
|
func (s *processSupervisor) doRestartLocked(ctx context.Context, reason string) error {
|
||||||
s.isRestarting.Store(true)
|
s.isRestarting.Store(true)
|
||||||
defer s.isRestarting.Store(false)
|
defer s.isRestarting.Store(false)
|
||||||
|
|
||||||
@@ -475,8 +590,8 @@ func (s *processSupervisor) doRestartLocked(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err := s.runWithDeadline(ctx, func() error {
|
err := s.tracedLaunch(ctx, reason, func() error {
|
||||||
return s.restart()
|
return s.runWithDeadline(ctx, s.restart)
|
||||||
})
|
})
|
||||||
|
|
||||||
for range acquired {
|
for range acquired {
|
||||||
@@ -514,6 +629,13 @@ func (s *processSupervisor) ActiveTasksCount() int64 {
|
|||||||
return s.activeTasks.Load()
|
return s.activeTasks.Load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConversionsSinceRestart returns the number of tasks handled since the last
|
||||||
|
// process (re)start. reqCounter is reset to zero on every restart and idle
|
||||||
|
// shutdown.
|
||||||
|
func (s *processSupervisor) ConversionsSinceRestart() int64 {
|
||||||
|
return s.reqCounter.Load()
|
||||||
|
}
|
||||||
|
|
||||||
// Interface guards.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ ProcessSupervisor = (*processSupervisor)(nil)
|
_ ProcessSupervisor = (*processSupervisor)(nil)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func TestProcessSupervisor_Launch(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
|
||||||
if tc.firstStartSet {
|
if tc.firstStartSet {
|
||||||
ps.firstStart.Store(true)
|
ps.firstStart.Store(true)
|
||||||
}
|
}
|
||||||
@@ -93,7 +93,7 @@ func TestProcessSupervisor_Shutdown(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0)
|
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0)
|
||||||
err := ps.Shutdown()
|
err := ps.Shutdown()
|
||||||
|
|
||||||
if !tc.expectError && err != nil {
|
if !tc.expectError && err != nil {
|
||||||
@@ -145,7 +145,7 @@ func TestProcessSupervisor_restart(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
|
||||||
|
|
||||||
err := ps.restart()
|
err := ps.restart()
|
||||||
|
|
||||||
@@ -186,10 +186,10 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
|
|||||||
expectHealthy: true,
|
expectHealthy: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "process reports as unhealthy",
|
scenario: "single probe failure is tolerated",
|
||||||
initiallyStarted: true,
|
initiallyStarted: true,
|
||||||
processHealthy: false,
|
processHealthy: false,
|
||||||
expectHealthy: false,
|
expectHealthy: true,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.scenario, func(t *testing.T) {
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
@@ -201,7 +201,7 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
|
||||||
if tc.initiallyStarted {
|
if tc.initiallyStarted {
|
||||||
ps.firstStart.Store(true)
|
ps.firstStart.Store(true)
|
||||||
}
|
}
|
||||||
@@ -218,6 +218,109 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProcessSupervisor_Healthy_ConsecutiveFailures verifies that only
|
||||||
|
// the second consecutive process-level failure flips the supervisor to
|
||||||
|
// unhealthy, and that a single success in between resets the counter.
|
||||||
|
func TestProcessSupervisor_Healthy_ConsecutiveFailures(t *testing.T) {
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
|
var processHealthy atomic.Bool
|
||||||
|
process := &ProcessMock{
|
||||||
|
HealthyMock: func(_ *slog.Logger) bool { return processHealthy.Load() },
|
||||||
|
}
|
||||||
|
|
||||||
|
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
|
||||||
|
ps.firstStart.Store(true)
|
||||||
|
|
||||||
|
processHealthy.Store(false)
|
||||||
|
if !ps.Healthy() {
|
||||||
|
t.Fatal("first failure should be tolerated and report healthy")
|
||||||
|
}
|
||||||
|
if ps.Healthy() {
|
||||||
|
t.Fatal("second consecutive failure should report unhealthy")
|
||||||
|
}
|
||||||
|
|
||||||
|
processHealthy.Store(true)
|
||||||
|
if !ps.Healthy() {
|
||||||
|
t.Fatal("recovery should report healthy immediately")
|
||||||
|
}
|
||||||
|
|
||||||
|
processHealthy.Store(false)
|
||||||
|
// Cache hit from the previous success absorbs the first new failure;
|
||||||
|
// invalidate it so we exercise the counter again.
|
||||||
|
ps.lastHealthyAt.Store(0)
|
||||||
|
if !ps.Healthy() {
|
||||||
|
t.Fatal("post-recovery first failure should be tolerated again")
|
||||||
|
}
|
||||||
|
if ps.Healthy() {
|
||||||
|
t.Fatal("post-recovery second consecutive failure should report unhealthy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessSupervisor_Healthy_CachesPositiveResult verifies that a
|
||||||
|
// successful probe is cached for [healthCheckCacheTTL] so subsequent
|
||||||
|
// supervisor.Healthy() calls do not re-issue the underlying process
|
||||||
|
// check.
|
||||||
|
func TestProcessSupervisor_Healthy_CachesPositiveResult(t *testing.T) {
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
|
var calls atomic.Int64
|
||||||
|
process := &ProcessMock{
|
||||||
|
HealthyMock: func(_ *slog.Logger) bool {
|
||||||
|
calls.Add(1)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
|
||||||
|
ps.firstStart.Store(true)
|
||||||
|
|
||||||
|
for range 5 {
|
||||||
|
if !ps.Healthy() {
|
||||||
|
t.Fatal("expected healthy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := calls.Load(); got != 1 {
|
||||||
|
t.Fatalf("process.Healthy called %d times, want exactly 1 (cache should absorb the other 4)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessSupervisor_Healthy_DoesNotCacheNegativeResult verifies that
|
||||||
|
// a probe failure is not cached: the next Healthy() call must re-issue
|
||||||
|
// the underlying process check so a recovered process surfaces on the
|
||||||
|
// very next probe.
|
||||||
|
func TestProcessSupervisor_Healthy_DoesNotCacheNegativeResult(t *testing.T) {
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
|
var calls atomic.Int64
|
||||||
|
var processHealthy atomic.Bool
|
||||||
|
process := &ProcessMock{
|
||||||
|
HealthyMock: func(_ *slog.Logger) bool {
|
||||||
|
calls.Add(1)
|
||||||
|
return processHealthy.Load()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
|
||||||
|
ps.firstStart.Store(true)
|
||||||
|
|
||||||
|
processHealthy.Store(false)
|
||||||
|
_ = ps.Healthy()
|
||||||
|
_ = ps.Healthy()
|
||||||
|
if got := calls.Load(); got != 2 {
|
||||||
|
t.Fatalf("after two failing probes, process.Healthy called %d times, want 2 (negative results must not be cached)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
processHealthy.Store(true)
|
||||||
|
if !ps.Healthy() {
|
||||||
|
t.Fatal("expected healthy on recovery")
|
||||||
|
}
|
||||||
|
if got := calls.Load(); got != 3 {
|
||||||
|
t.Fatalf("after recovery, process.Healthy called %d times, want 3", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProcessSupervisor_Run(t *testing.T) {
|
func TestProcessSupervisor_Run(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
scenario string
|
scenario string
|
||||||
@@ -386,7 +489,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit, tc.maxQueueSize, 1, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, "test", process, tc.maxReqLimit, tc.maxQueueSize, 1, 0).(*processSupervisor)
|
||||||
if tc.initiallyStarted {
|
if tc.initiallyStarted {
|
||||||
ps.firstStart.Store(true)
|
ps.firstStart.Store(true)
|
||||||
}
|
}
|
||||||
@@ -470,7 +573,7 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
|
|||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.scenario, func(t *testing.T) {
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
ps := NewProcessSupervisor(slog.New(slog.DiscardHandler), new(ProcessMock), 0, 0, 1, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(slog.New(slog.DiscardHandler), "test", new(ProcessMock), 0, 0, 1, 0).(*processSupervisor)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||||
if tc.ctxDone {
|
if tc.ctxDone {
|
||||||
@@ -504,7 +607,7 @@ func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
|
|||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, 0).(*processSupervisor)
|
||||||
|
|
||||||
// Simulating a lock.
|
// Simulating a lock.
|
||||||
ps.semaphore <- struct{}{}
|
ps.semaphore <- struct{}{}
|
||||||
@@ -565,7 +668,7 @@ func TestProcessSupervisor_QueueSizeCAS(t *testing.T) {
|
|||||||
|
|
||||||
maxQueueSize := int64(50)
|
maxQueueSize := int64(50)
|
||||||
// maxConcurrency=1 so all goroutines block on the semaphore, exercising queue logic.
|
// maxConcurrency=1 so all goroutines block on the semaphore, exercising queue logic.
|
||||||
ps := NewProcessSupervisor(logger, process, 0, maxQueueSize, 1, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, "test", process, 0, maxQueueSize, 1, 0).(*processSupervisor)
|
||||||
|
|
||||||
// Simulating a lock so that all goroutines queue up.
|
// Simulating a lock so that all goroutines queue up.
|
||||||
ps.semaphore <- struct{}{}
|
ps.semaphore <- struct{}{}
|
||||||
@@ -619,7 +722,7 @@ func TestProcessSupervisor_QueueSizeIncludesActiveTasks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// maxQueueSize=1, maxConcurrency=1: only one request at a time.
|
// maxQueueSize=1, maxConcurrency=1: only one request at a time.
|
||||||
ps := NewProcessSupervisor(logger, process, 0, 1, 1, 0)
|
ps := NewProcessSupervisor(logger, "test", process, 0, 1, 1, 0)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -708,7 +811,7 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, 0).(*processSupervisor)
|
||||||
ps.restartsCounter.Store(tc.initialRestartsCount)
|
ps.restartsCounter.Store(tc.initialRestartsCount)
|
||||||
|
|
||||||
for i := 0; i < tc.restartAttempts; i++ {
|
for i := 0; i < tc.restartAttempts; i++ {
|
||||||
@@ -741,7 +844,7 @@ func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
maxConcurrency := int64(3)
|
maxConcurrency := int64(3)
|
||||||
ps := NewProcessSupervisor(logger, process, 0, 0, maxConcurrency, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, "test", process, 0, 0, maxConcurrency, 0).(*processSupervisor)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -803,7 +906,7 @@ func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
maxConcurrency := int64(3)
|
maxConcurrency := int64(3)
|
||||||
ps := NewProcessSupervisor(logger, process, 3, 0, maxConcurrency, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, "test", process, 3, 0, maxConcurrency, 0).(*processSupervisor)
|
||||||
ps.firstStart.Store(true)
|
ps.firstStart.Store(true)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
@@ -860,7 +963,7 @@ func TestProcessSupervisor_IdleShutdown(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
idleTimeout := 50 * time.Millisecond
|
idleTimeout := 50 * time.Millisecond
|
||||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, idleTimeout).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, idleTimeout).(*processSupervisor)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
err := ps.Run(ctx, logger, func() error {
|
err := ps.Run(ctx, logger, func() error {
|
||||||
@@ -920,7 +1023,7 @@ func TestProcessSupervisor_RetryAfterFailedFirstStart(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, 0).(*processSupervisor)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -966,7 +1069,7 @@ func TestProcessSupervisor_IdleShutdownSkippedWhenActive(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
idleTimeout := 50 * time.Millisecond
|
idleTimeout := 50 * time.Millisecond
|
||||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, idleTimeout)
|
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, idleTimeout)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
go func() {
|
go func() {
|
||||||
@@ -988,3 +1091,70 @@ func TestProcessSupervisor_IdleShutdownSkippedWhenActive(t *testing.T) {
|
|||||||
|
|
||||||
close(taskDone)
|
close(taskDone)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProcessSupervisor_ConversionsSinceRestart(t *testing.T) {
|
||||||
|
process := &ProcessMock{
|
||||||
|
StartMock: func(*slog.Logger) error { return nil },
|
||||||
|
StopMock: func(*slog.Logger) error { return nil },
|
||||||
|
HealthyMock: func(*slog.Logger) bool { return true },
|
||||||
|
}
|
||||||
|
s := NewProcessSupervisor(slog.New(slog.DiscardHandler), "test", process, 0, 0, 1, 0).(*processSupervisor)
|
||||||
|
|
||||||
|
if got := s.ConversionsSinceRestart(); got != 0 {
|
||||||
|
t.Errorf("expected 0 conversions initially, got %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.reqCounter.Store(7)
|
||||||
|
if got := s.ConversionsSinceRestart(); got != 7 {
|
||||||
|
t.Errorf("expected 7 conversions, got %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.restart(); err != nil {
|
||||||
|
t.Fatalf("restart: %v", err)
|
||||||
|
}
|
||||||
|
if got := s.ConversionsSinceRestart(); got != 0 {
|
||||||
|
t.Errorf("expected 0 conversions after restart, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessSupervisor_RunEmitsSubSpans(t *testing.T) {
|
||||||
|
recorder := newTestSpanRecorder(t)
|
||||||
|
|
||||||
|
process := &ProcessMock{
|
||||||
|
StartMock: func(*slog.Logger) error { return nil },
|
||||||
|
StopMock: func(*slog.Logger) error { return nil },
|
||||||
|
HealthyMock: func(*slog.Logger) bool { return true },
|
||||||
|
}
|
||||||
|
s := NewProcessSupervisor(slog.New(slog.DiscardHandler), "chromium", process, 0, 0, 1, 0)
|
||||||
|
|
||||||
|
err := s.Run(context.Background(), slog.New(slog.DiscardHandler), func() error { return nil })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var queueWaits, processStarts int
|
||||||
|
var startReason string
|
||||||
|
for _, span := range recorder.Ended() {
|
||||||
|
switch span.Name() {
|
||||||
|
case "chromium.queue.wait":
|
||||||
|
queueWaits++
|
||||||
|
case "chromium.process.start":
|
||||||
|
processStarts++
|
||||||
|
for _, kv := range span.Attributes() {
|
||||||
|
if string(kv.Key) == "gotenberg.process.start.reason" {
|
||||||
|
startReason = kv.Value.AsString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if queueWaits != 1 {
|
||||||
|
t.Errorf("expected 1 chromium.queue.wait span, got %d", queueWaits)
|
||||||
|
}
|
||||||
|
if processStarts != 1 {
|
||||||
|
t.Errorf("expected 1 chromium.process.start span (first start), got %d", processStarts)
|
||||||
|
}
|
||||||
|
if startReason != "first_start" {
|
||||||
|
t.Errorf("expected process start reason first_start, got %q", startReason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ const (
|
|||||||
DebugLoggingLevel = "debug"
|
DebugLoggingLevel = "debug"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
LowerLevelCase = "lower"
|
||||||
|
UpperLevelCase = "upper"
|
||||||
|
)
|
||||||
|
|
||||||
// TelemetryConfig gathers the configuration data for Gotenberg's telemetry.
|
// TelemetryConfig gathers the configuration data for Gotenberg's telemetry.
|
||||||
type TelemetryConfig struct {
|
type TelemetryConfig struct {
|
||||||
ServiceName string
|
ServiceName string
|
||||||
@@ -38,6 +43,7 @@ type TelemetryConfig struct {
|
|||||||
LogFieldsPrefix string
|
LogFieldsPrefix string
|
||||||
LogStdFormat string
|
LogStdFormat string
|
||||||
LogStdEnableGcpFields bool
|
LogStdEnableGcpFields bool
|
||||||
|
LogStdLevelCase string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cfg TelemetryConfig) slogLevel() slog.Level {
|
func (cfg TelemetryConfig) slogLevel() slog.Level {
|
||||||
@@ -85,6 +91,16 @@ func (cfg TelemetryConfig) Validate() error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch cfg.LogStdLevelCase {
|
||||||
|
case LowerLevelCase, UpperLevelCase:
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
err = errors.Join(
|
||||||
|
err,
|
||||||
|
fmt.Errorf("standard log level case must be either %s or %s", LowerLevelCase, UpperLevelCase),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +108,7 @@ func (cfg TelemetryConfig) Validate() error {
|
|||||||
func StartTelemetry(cfg TelemetryConfig) (shutdown func(context.Context) error, err error) {
|
func StartTelemetry(cfg TelemetryConfig) (shutdown func(context.Context) error, err error) {
|
||||||
var handlers []slog.Handler
|
var handlers []slog.Handler
|
||||||
|
|
||||||
stdHandler, err := log.NewStdHandler(cfg.slogLevel(), cfg.LogStdFormat, cfg.LogFieldsPrefix, cfg.LogStdEnableGcpFields)
|
stdHandler, err := log.NewStdHandler(cfg.slogLevel(), cfg.LogStdFormat, cfg.LogFieldsPrefix, cfg.LogStdEnableGcpFields, cfg.LogStdLevelCase)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get standard logger handler: %w", err)
|
return nil, fmt.Errorf("get standard logger handler: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,12 +57,13 @@ type Api struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type downloadFromConfig struct {
|
type downloadFromConfig struct {
|
||||||
allowList []*regexp2.Regexp
|
allowList []*regexp2.Regexp
|
||||||
denyList []*regexp2.Regexp
|
denyList []*regexp2.Regexp
|
||||||
denyPrivateIPs bool
|
denyPrivateIPs bool
|
||||||
denyPublicIPs bool
|
denyPublicIPs bool
|
||||||
maxRetry int
|
enableEnvironmentProxy bool
|
||||||
disable bool
|
maxRetry int
|
||||||
|
disable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Router is a module interface that adds routes to the [Api].
|
// 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.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-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-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.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-download-from", false, "Disable the download from feature")
|
||||||
fs.Bool("api-disable-health-check-route-telemetry", true, "Disable telemetry for health check route")
|
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.rootPath = flags.MustString("api-root-path")
|
||||||
a.correlationIdHeader = flags.MustDeprecatedString("api-trace-header", "api-correlation-id-header")
|
a.correlationIdHeader = flags.MustDeprecatedString("api-trace-header", "api-correlation-id-header")
|
||||||
a.downloadFromCfg = downloadFromConfig{
|
a.downloadFromCfg = downloadFromConfig{
|
||||||
allowList: flags.MustRegexpSlice("api-download-from-allow-list"),
|
allowList: flags.MustRegexpSlice("api-download-from-allow-list"),
|
||||||
denyList: flags.MustRegexpSlice("api-download-from-deny-list"),
|
denyList: flags.MustRegexpSlice("api-download-from-deny-list"),
|
||||||
denyPrivateIPs: flags.MustBool("api-download-from-deny-private-ips"),
|
denyPrivateIPs: flags.MustBool("api-download-from-deny-private-ips"),
|
||||||
denyPublicIPs: flags.MustBool("api-download-from-deny-public-ips"),
|
denyPublicIPs: flags.MustBool("api-download-from-deny-public-ips"),
|
||||||
maxRetry: flags.MustInt("api-download-from-max-retry"),
|
enableEnvironmentProxy: flags.MustBool("api-download-from-enable-environment-proxy"),
|
||||||
disable: flags.MustBool("api-disable-download-from"),
|
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.disableHealthCheckRouteTelemetry = flags.MustDeprecatedBool("api-disable-health-check-logging", "api-disable-health-check-route-telemetry")
|
||||||
a.disableRootRouteTelemetry = flags.MustBool("api-disable-root-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 {
|
if bodyLimit != 0 && newTotal > bodyLimit {
|
||||||
return WrapError(
|
return WrapError(
|
||||||
fmt.Errorf("body limit reached (> %d)", bodyLimit),
|
fmt.Errorf("body limit reached (> %d)", bodyLimit),
|
||||||
NewSentinelHttpError(http.StatusRequestEntityTooLarge, http.StatusText(http.StatusRequestEntityTooLarge)),
|
NewSentinelHttpError(http.StatusRequestEntityTooLarge, "The request body exceeds the configured size limit. Increase it with --api-body-limit, or send a smaller request."),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -216,6 +216,15 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each goroutine writes to its own results slot. The main
|
||||||
|
// goroutine merges into ctx.files, ctx.diskToOriginal, and
|
||||||
|
// ctx.filesByField after eg.Wait() to avoid concurrent map
|
||||||
|
// writes.
|
||||||
|
type downloadFromResult struct {
|
||||||
|
filename, path, formField string
|
||||||
|
}
|
||||||
|
results := make([]downloadFromResult, len(dls))
|
||||||
|
|
||||||
eg, _ := errgroup.WithContext(ctx)
|
eg, _ := errgroup.WithContext(ctx)
|
||||||
for i, dl := range dls {
|
for i, dl := range dls {
|
||||||
eg.Go(func() error {
|
eg.Go(func() error {
|
||||||
@@ -272,7 +281,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
}
|
}
|
||||||
|
|
||||||
client := &retryablehttp.Client{
|
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,
|
RetryMax: downloadFromCfg.maxRetry,
|
||||||
RetryWaitMin: time.Duration(1) * time.Second,
|
RetryWaitMin: time.Duration(1) * time.Second,
|
||||||
RetryWaitMax: time.Until(deadline),
|
RetryWaitMax: time.Until(deadline),
|
||||||
@@ -348,10 +357,13 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avoid directory traversal and make sure filename characters are
|
// Strip path separators (including backslashes) and control
|
||||||
// normalized.
|
// characters, then NFC-normalize. Defends against directory
|
||||||
|
// traversal in the on-disk name and Windows-side Zip Slip
|
||||||
|
// when the original filename is later embedded in an output
|
||||||
|
// zip entry.
|
||||||
// See: https://github.com/gotenberg/gotenberg/issues/662.
|
// See: https://github.com/gotenberg/gotenberg/issues/662.
|
||||||
filename = norm.NFC.String(filepath.Base(filename))
|
filename = sanitizeFilename(filename)
|
||||||
|
|
||||||
// Use a UUID-based name on disk to avoid filesystem
|
// Use a UUID-based name on disk to avoid filesystem
|
||||||
// NAME_MAX limits with long filenames.
|
// NAME_MAX limits with long filenames.
|
||||||
@@ -389,18 +401,16 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
dlSpan.SetStatus(codes.Ok, "")
|
dlSpan.SetStatus(codes.Ok, "")
|
||||||
dlSpan.End()
|
dlSpan.End()
|
||||||
|
|
||||||
ctx.files[filename] = path
|
var formField string
|
||||||
ctx.diskToOriginal[path] = filename
|
|
||||||
|
|
||||||
// Route the downloaded file to the appropriate field bucket.
|
|
||||||
switch {
|
switch {
|
||||||
case dl.Field == "embedded" || dl.Embedded:
|
case dl.Field == "embedded" || dl.Embedded:
|
||||||
ctx.filesByField[EmbedsFormField] = append(ctx.filesByField[EmbedsFormField], path)
|
formField = EmbedsFormField
|
||||||
case dl.Field == "watermark":
|
case dl.Field == "watermark":
|
||||||
ctx.filesByField[WatermarkFormField] = append(ctx.filesByField[WatermarkFormField], path)
|
formField = WatermarkFormField
|
||||||
case dl.Field == "stamp":
|
case dl.Field == "stamp":
|
||||||
ctx.filesByField[StampFormField] = append(ctx.filesByField[StampFormField], path)
|
formField = StampFormField
|
||||||
}
|
}
|
||||||
|
results[i] = downloadFromResult{filename: filename, path: path, formField: formField}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -410,6 +420,14 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ctx, cancel, err
|
return ctx, cancel, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, r := range results {
|
||||||
|
ctx.files[r.filename] = r.path
|
||||||
|
ctx.diskToOriginal[r.path] = r.filename
|
||||||
|
if r.formField != "" {
|
||||||
|
ctx.filesByField[r.formField] = append(ctx.filesByField[r.formField], r.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
copyToDisk := func(fh *multipart.FileHeader) error {
|
copyToDisk := func(fh *multipart.FileHeader) error {
|
||||||
@@ -428,10 +446,12 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
// This will ensure we do not exceed the body limit.
|
// This will ensure we do not exceed the body limit.
|
||||||
reader := &trackingReader{R: in, AddReadBytes: addReadBytes}
|
reader := &trackingReader{R: in, AddReadBytes: addReadBytes}
|
||||||
|
|
||||||
// Avoid directory traversal and make sure filename characters are
|
// Strip path separators (including backslashes) and control
|
||||||
// normalized.
|
// characters, then NFC-normalize. Defends against directory
|
||||||
|
// traversal in the on-disk name and Windows-side Zip Slip when the
|
||||||
|
// original filename is later embedded in an output zip entry.
|
||||||
// See: https://github.com/gotenberg/gotenberg/issues/662.
|
// See: https://github.com/gotenberg/gotenberg/issues/662.
|
||||||
filename := norm.NFC.String(filepath.Base(fh.Filename))
|
filename := sanitizeFilename(fh.Filename)
|
||||||
|
|
||||||
// Use a UUID-based name on disk to avoid filesystem
|
// Use a UUID-based name on disk to avoid filesystem
|
||||||
// NAME_MAX limits with long filenames.
|
// NAME_MAX limits with long filenames.
|
||||||
@@ -469,7 +489,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
return ctx, cancel, fmt.Errorf("copy to disk: %w", err)
|
return ctx, cancel, fmt.Errorf("copy to disk: %w", err)
|
||||||
}
|
}
|
||||||
// Track files by field name
|
// Track files by field name
|
||||||
filename := norm.NFC.String(filepath.Base(fh.Filename))
|
filename := sanitizeFilename(fh.Filename)
|
||||||
filePath := ctx.files[filename]
|
filePath := ctx.files[filename]
|
||||||
ctx.filesByField[fieldName] = append(ctx.filesByField[fieldName], filePath)
|
ctx.filesByField[fieldName] = append(ctx.filesByField[fieldName], filePath)
|
||||||
}
|
}
|
||||||
@@ -516,6 +536,11 @@ func (ctx *Context) FormData() *FormData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FileCount returns the number of files received in the request.
|
||||||
|
func (ctx *Context) FileCount() int {
|
||||||
|
return len(ctx.files)
|
||||||
|
}
|
||||||
|
|
||||||
// OriginalFilename returns the original filename associated with a disk path.
|
// OriginalFilename returns the original filename associated with a disk path.
|
||||||
// If no mapping exists, it falls back to [filepath.Base].
|
// If no mapping exists, it falls back to [filepath.Base].
|
||||||
func (ctx *Context) OriginalFilename(diskPath string) string {
|
func (ctx *Context) OriginalFilename(diskPath string) string {
|
||||||
@@ -658,3 +683,21 @@ func (ctx *Context) OutputFilename(outputPath string) string {
|
|||||||
|
|
||||||
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))
|
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sanitizeFilename strips path separators (including backslashes, which
|
||||||
|
// [filepath.Base] ignores on Linux) and control characters from a
|
||||||
|
// caller-supplied filename, then NFC-normalizes the result. This prevents a
|
||||||
|
// Windows-side Zip Slip when an output zip is extracted by a permissive
|
||||||
|
// extractor that interprets '\' as a path separator.
|
||||||
|
func sanitizeFilename(name string) string {
|
||||||
|
if i := strings.LastIndexAny(name, `/\`); i >= 0 {
|
||||||
|
name = name[i+1:]
|
||||||
|
}
|
||||||
|
name = strings.Map(func(r rune) rune {
|
||||||
|
if r < 0x20 || r == 0x7f {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, name)
|
||||||
|
return norm.NFC.String(name)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ package api
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -69,3 +72,153 @@ func TestNewContext_Cancellation(t *testing.T) {
|
|||||||
t.Fatal("expected context to be cancelled after request context cancellation, but it timed out")
|
t.Fatal("expected context to be cancelled after request context cancellation, but it timed out")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Concurrent downloadFrom entries must not race on the shared maps
|
||||||
|
// (ctx.files, ctx.diskToOriginal, ctx.filesByField). Run under -race
|
||||||
|
// to catch the data race; without -race a sufficient number of entries
|
||||||
|
// still surfaces "fatal error: concurrent map writes".
|
||||||
|
func TestNewContext_DownloadFromConcurrentMapWrites(t *testing.T) {
|
||||||
|
const downloads = 64
|
||||||
|
|
||||||
|
var ready sync.WaitGroup
|
||||||
|
ready.Add(downloads)
|
||||||
|
release := make(chan struct{})
|
||||||
|
var releaseOnce sync.Once
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ready.Done()
|
||||||
|
go func() {
|
||||||
|
ready.Wait()
|
||||||
|
releaseOnce.Do(func() { close(release) })
|
||||||
|
}()
|
||||||
|
<-release
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("download-%s.txt", r.URL.Query().Get("i"))
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||||
|
_, _ = w.Write([]byte("downloaded"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
dls := make([]downloadFrom, downloads)
|
||||||
|
for i := range dls {
|
||||||
|
dls[i] = downloadFrom{
|
||||||
|
Url: fmt.Sprintf("%s/file?i=%d", server.URL, i),
|
||||||
|
Field: "embedded",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(dls)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal downloadFrom payload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := new(bytes.Buffer)
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
err = writer.WriteField("downloadFrom", string(payload))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write downloadFrom field: %v", err)
|
||||||
|
}
|
||||||
|
err = writer.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close multipart writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/forms/libreoffice/convert", body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
|
echoCtx := echo.New().NewContext(req, httptest.NewRecorder())
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
||||||
|
downloadFromCfg := downloadFromConfig{
|
||||||
|
maxRetry: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromCfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newContext returned error: %v", err)
|
||||||
|
}
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if got := len(ctx.files); got != downloads {
|
||||||
|
t.Fatalf("downloaded files = %d, want %d", got, downloads)
|
||||||
|
}
|
||||||
|
if got := len(ctx.diskToOriginal); got != downloads {
|
||||||
|
t.Fatalf("diskToOriginal entries = %d, want %d", got, downloads)
|
||||||
|
}
|
||||||
|
if got := len(ctx.filesByField[EmbedsFormField]); got != downloads {
|
||||||
|
t.Fatalf("filesByField[%q] entries = %d, want %d", EmbedsFormField, got, downloads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeFilename(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
input string
|
||||||
|
expect string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "plain filename is unchanged",
|
||||||
|
input: "report.pdf",
|
||||||
|
expect: "report.pdf",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "POSIX traversal is stripped",
|
||||||
|
input: "../../etc/passwd",
|
||||||
|
expect: "passwd",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "Windows traversal with backslashes is stripped",
|
||||||
|
input: `..\..\..\..\Windows\System32\evil.pdf`,
|
||||||
|
expect: "evil.pdf",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "mixed separators take the last segment",
|
||||||
|
input: `foo/bar\baz.pdf`,
|
||||||
|
expect: "baz.pdf",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "control characters are dropped",
|
||||||
|
input: "evil\x00\x07\x1f\x7f.pdf",
|
||||||
|
expect: "evil.pdf",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "NFC normalization collapses decomposed sequences",
|
||||||
|
// "e" + combining acute accent -> precomposed "é".
|
||||||
|
input: "café.pdf",
|
||||||
|
expect: "café.pdf",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "trailing backslash yields empty name",
|
||||||
|
input: `foo\`,
|
||||||
|
expect: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "empty input yields empty name",
|
||||||
|
input: "",
|
||||||
|
expect: "",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
got := sanitizeFilename(tc.input)
|
||||||
|
if got != tc.expect {
|
||||||
|
t.Errorf("sanitizeFilename(%q) = %q, want %q", tc.input, got, tc.expect)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContext_FileCount(t *testing.T) {
|
||||||
|
ctx := &Context{}
|
||||||
|
if got := ctx.FileCount(); got != 0 {
|
||||||
|
t.Errorf("expected 0 files, got %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.files = map[string]string{
|
||||||
|
"index.html": "/work/index.html",
|
||||||
|
"header.html": "/work/header.html",
|
||||||
|
"styles.css": "/work/styles.css",
|
||||||
|
}
|
||||||
|
if got := ctx.FileCount(); got != 3 {
|
||||||
|
t.Errorf("expected 3 files, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ const (
|
|||||||
|
|
||||||
// StampFormField represents the form field name for the stamp file.
|
// StampFormField represents the form field name for the stamp file.
|
||||||
StampFormField string = "stamp"
|
StampFormField string = "stamp"
|
||||||
|
|
||||||
|
// FacturXXmlFormField represents the form field name for the Factur-X CII
|
||||||
|
// invoice XML file.
|
||||||
|
FacturXXmlFormField string = "facturxXml"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FormData is a helper for validating and hydrating values from a
|
// FormData is a helper for validating and hydrating values from a
|
||||||
@@ -475,13 +479,28 @@ func (form *FormData) Stamp(target *string) *FormData {
|
|||||||
return form
|
return form
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FacturXXml binds the absolute path of the uploaded Factur-X CII invoice
|
||||||
|
// XML. Only a file uploaded with the "facturxXml" field name is included.
|
||||||
|
func (form *FormData) FacturXXml(target *string) *FormData {
|
||||||
|
if form.errors != nil {
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
if paths, ok := form.filesByField[FacturXXmlFormField]; ok && len(paths) > 0 {
|
||||||
|
*target = paths[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
// paths bind the absolute paths of form data files, according to a list of
|
// paths bind the absolute paths of form data files, according to a list of
|
||||||
// file extensions, to a string slice variable.
|
// file extensions, to a string slice variable.
|
||||||
// embeds, watermark, and stamp files are excluded.
|
// embeds, watermark, stamp, and facturxXml files are excluded.
|
||||||
func (form *FormData) paths(extensions []string, target *[]string) *FormData {
|
func (form *FormData) paths(extensions []string, target *[]string) *FormData {
|
||||||
embeds, ok := form.filesByField[EmbedsFormField]
|
embeds, ok := form.filesByField[EmbedsFormField]
|
||||||
watermarks, wmOk := form.filesByField[WatermarkFormField]
|
watermarks, wmOk := form.filesByField[WatermarkFormField]
|
||||||
stamps, stOk := form.filesByField[StampFormField]
|
stamps, stOk := form.filesByField[StampFormField]
|
||||||
|
facturxXmls, fxOk := form.filesByField[FacturXXmlFormField]
|
||||||
|
|
||||||
// Collect (originalFilename, diskPath) pairs so that we can sort by
|
// Collect (originalFilename, diskPath) pairs so that we can sort by
|
||||||
// original filename rather than by UUID-based disk name.
|
// original filename rather than by UUID-based disk name.
|
||||||
@@ -505,6 +524,10 @@ func (form *FormData) paths(extensions []string, target *[]string) *FormData {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if fxOk && slices.Contains(facturxXmls, path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
for _, ext := range extensions {
|
for _, ext := range extensions {
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/228.
|
// See https://github.com/gotenberg/gotenberg/issues/228.
|
||||||
if strings.ToLower(filepath.Ext(filename)) == ext {
|
if strings.ToLower(filepath.Ext(filename)) == ext {
|
||||||
|
|||||||
@@ -1783,3 +1783,57 @@ func TestFormData_Embeds(t *testing.T) {
|
|||||||
t.Errorf("expected %v but got %v", expected, actual)
|
t.Errorf("expected %v but got %v", expected, actual)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFormData_FacturXXml(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
form *FormData
|
||||||
|
expect string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "no facturxXml file",
|
||||||
|
form: &FormData{},
|
||||||
|
expect: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "facturxXml file present",
|
||||||
|
form: &FormData{
|
||||||
|
filesByField: map[string][]string{
|
||||||
|
FacturXXmlFormField: {"/tmp/abc/12345.xml"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expect: "/tmp/abc/12345.xml",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
var actual string
|
||||||
|
|
||||||
|
tc.form.FacturXXml(&actual)
|
||||||
|
|
||||||
|
if actual != tc.expect {
|
||||||
|
t.Errorf("expected %q but got %q", tc.expect, actual)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormData_paths_excludesFacturXXml verifies that an uploaded facturxXml is
|
||||||
|
// never picked up as an input document by paths().
|
||||||
|
func TestFormData_paths_excludesFacturXXml(t *testing.T) {
|
||||||
|
form := &FormData{
|
||||||
|
files: map[string]string{
|
||||||
|
"document.xml": "/tmp/abc/document.xml",
|
||||||
|
"factur-x.xml": "/tmp/abc/invoice.xml",
|
||||||
|
},
|
||||||
|
filesByField: map[string][]string{
|
||||||
|
FacturXXmlFormField: {"/tmp/abc/invoice.xml"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var paths []string
|
||||||
|
form.paths([]string{".xml"}, &paths)
|
||||||
|
|
||||||
|
if len(paths) != 1 || paths[0] != "/tmp/abc/document.xml" {
|
||||||
|
t.Errorf("expected only the non-Factur-X .xml document, got %+v", paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func ParseError(err error) (int, string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, context.DeadlineExceeded) {
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
return http.StatusServiceUnavailable, http.StatusText(http.StatusServiceUnavailable)
|
return http.StatusServiceUnavailable, "The request exceeded the time limit. Increase it with --api-timeout, or reduce the workload."
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, gotenberg.ErrFiltered) {
|
if errors.Is(err, gotenberg.ErrFiltered) {
|
||||||
@@ -51,27 +51,27 @@ func ParseError(err error) (int, string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
|
if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
|
||||||
return http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests)
|
return http.StatusTooManyRequests, "The request queue is full. Retry shortly, or raise the limit with --chromium-max-queue-size or --libreoffice-max-queue-size."
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, gotenberg.ErrPdfSplitModeNotSupported) {
|
if errors.Is(err, gotenberg.ErrPdfSplitModeNotSupported) {
|
||||||
return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues"
|
return http.StatusBadRequest, "The requested split mode is not supported, or no PDF engine could process it. Valid modes: 'intervals', 'pages'."
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
|
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
|
||||||
return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues"
|
return http.StatusBadRequest, "The requested PDF format is not supported, or no PDF engine could apply it. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA."
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, gotenberg.ErrPdfEngineMetadataValueNotSupported) {
|
if errors.Is(err, gotenberg.ErrPdfEngineMetadataValueNotSupported) {
|
||||||
return http.StatusBadRequest, "At least one PDF engine cannot process the requested metadata, while others may have failed to convert due to different issues"
|
return http.StatusBadRequest, "The requested metadata could not be written; ensure values are valid and free of control characters."
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, gotenberg.ErrPdfStampSourceNotSupported) {
|
if errors.Is(err, gotenberg.ErrPdfStampSourceNotSupported) {
|
||||||
return http.StatusBadRequest, "At least one PDF engine cannot process the requested stamp source type, while others may have failed due to different issues"
|
return http.StatusBadRequest, "The requested stamp source is not supported, or no PDF engine could process it. Valid sources: 'text', 'image', 'pdf'."
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, gotenberg.ErrPdfRotateAngleNotSupported) {
|
if errors.Is(err, gotenberg.ErrPdfRotateAngleNotSupported) {
|
||||||
return http.StatusBadRequest, "At least one PDF engine cannot process the requested rotation angle, while others may have failed due to different issues"
|
return http.StatusBadRequest, "The requested rotation angle is not supported. Valid angles: 90, 180, 270."
|
||||||
}
|
}
|
||||||
|
|
||||||
if invalidArgsError, ok := errors.AsType[*gotenberg.PdfEngineInvalidArgsError](err); ok {
|
if invalidArgsError, ok := errors.AsType[*gotenberg.PdfEngineInvalidArgsError](err); ok {
|
||||||
|
|||||||
87
pkg/modules/chromium/attrs_test.go
Normal file
87
pkg/modules/chromium/attrs_test.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package chromium
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrintToPdfAttrs(t *testing.T) {
|
||||||
|
options := DefaultPdfOptions()
|
||||||
|
options.Landscape = true
|
||||||
|
options.PageRanges = "1-5"
|
||||||
|
options.HeaderTemplate = "<div>secret header</div>"
|
||||||
|
// FooterTemplate left at default, so has_footer must be false.
|
||||||
|
|
||||||
|
got := map[string]attribute.Value{}
|
||||||
|
for _, kv := range printToPdfAttrs(options) {
|
||||||
|
got[string(kv.Key)] = kv.Value
|
||||||
|
if s := kv.Value.AsString(); strings.Contains(s, "secret") || s == "1-5" {
|
||||||
|
t.Errorf("attribute %s leaked a raw value: %q", kv.Key, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !got["gotenberg.chromium.print.landscape"].AsBool() {
|
||||||
|
t.Error("expected landscape=true")
|
||||||
|
}
|
||||||
|
if !got["gotenberg.chromium.print.has_page_ranges"].AsBool() {
|
||||||
|
t.Error("expected has_page_ranges=true")
|
||||||
|
}
|
||||||
|
if !got["gotenberg.chromium.print.has_header"].AsBool() {
|
||||||
|
t.Error("expected has_header=true")
|
||||||
|
}
|
||||||
|
if got["gotenberg.chromium.print.has_footer"].AsBool() {
|
||||||
|
t.Error("expected has_footer=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConversionInputAttrs(t *testing.T) {
|
||||||
|
tmp := filepath.Join(t.TempDir(), "index.html")
|
||||||
|
content := []byte("<html></html>")
|
||||||
|
if err := os.WriteFile(tmp, content, 0o600); err != nil {
|
||||||
|
t.Fatalf("write temp file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("file URL with non-api context", func(t *testing.T) {
|
||||||
|
got := map[string]int64{}
|
||||||
|
for _, kv := range conversionInputAttrs(context.Background(), "file://"+tmp) {
|
||||||
|
got[string(kv.Key)] = kv.Value.AsInt64()
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := got["gotenberg.conversion.input.files.count"]; ok {
|
||||||
|
t.Error("did not expect files.count for a non-api context")
|
||||||
|
}
|
||||||
|
if got["gotenberg.conversion.input.html.bytes"] != int64(len(content)) {
|
||||||
|
t.Errorf("expected html.bytes=%d, got %d", len(content), got["gotenberg.conversion.input.html.bytes"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("remote URL yields no html.bytes", func(t *testing.T) {
|
||||||
|
for _, kv := range conversionInputAttrs(context.Background(), "https://example.com") {
|
||||||
|
if string(kv.Key) == "gotenberg.conversion.input.html.bytes" {
|
||||||
|
t.Error("did not expect html.bytes for a remote URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("api context yields files.count", func(t *testing.T) {
|
||||||
|
var found bool
|
||||||
|
for _, kv := range conversionInputAttrs(&api.Context{}, "https://example.com") {
|
||||||
|
if string(kv.Key) == "gotenberg.conversion.input.files.count" {
|
||||||
|
found = true
|
||||||
|
if kv.Value.AsInt64() != 0 {
|
||||||
|
t.Errorf("expected files.count=0, got %d", kv.Value.AsInt64())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("expected files.count for an api context")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -25,8 +25,8 @@ import (
|
|||||||
|
|
||||||
type browser interface {
|
type browser interface {
|
||||||
gotenberg.Process
|
gotenberg.Process
|
||||||
pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error
|
pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions, aggregate *networkAggregate) error
|
||||||
screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error
|
screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions, aggregate *networkAggregate) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type browserArguments struct {
|
type browserArguments struct {
|
||||||
@@ -38,6 +38,7 @@ type browserArguments struct {
|
|||||||
allowFileAccessFromFiles bool
|
allowFileAccessFromFiles bool
|
||||||
hostResolverRules string
|
hostResolverRules string
|
||||||
proxyServer string
|
proxyServer string
|
||||||
|
enableEnvironmentProxy bool
|
||||||
wsUrlReadTimeout time.Duration
|
wsUrlReadTimeout time.Duration
|
||||||
hyphenDataDirPath string
|
hyphenDataDirPath string
|
||||||
|
|
||||||
@@ -58,6 +59,14 @@ type chromiumBrowser struct {
|
|||||||
userProfileDirPath string
|
userProfileDirPath string
|
||||||
ctxMu sync.RWMutex
|
ctxMu sync.RWMutex
|
||||||
isStarted atomic.Bool
|
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
|
arguments browserArguments
|
||||||
fs *gotenberg.FileSystem
|
fs *gotenberg.FileSystem
|
||||||
@@ -69,7 +78,7 @@ func newChromiumBrowser(arguments browserArguments) browser {
|
|||||||
initialCtx: context.Background(),
|
initialCtx: context.Background(),
|
||||||
arguments: arguments,
|
arguments: arguments,
|
||||||
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
|
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)
|
b.isStarted.Store(false)
|
||||||
|
|
||||||
@@ -77,6 +86,19 @@ func newChromiumBrowser(arguments browserArguments) browser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
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() {
|
if b.isStarted.Load() {
|
||||||
return errors.New("browser is already started")
|
return errors.New("browser is already started")
|
||||||
}
|
}
|
||||||
@@ -169,6 +191,15 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
cancel()
|
cancel()
|
||||||
allocatorCancel()
|
allocatorCancel()
|
||||||
|
// The pinning proxy started before chromedp; tear it down so a
|
||||||
|
// supervisor retry can re-bind. Stop is a no-op when the proxy
|
||||||
|
// was never started (operator-configured --chromium-proxy-server
|
||||||
|
// or --chromium-host-resolver-rules).
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1559.
|
||||||
|
stopErr := b.pinningProxy.Stop(logger)
|
||||||
|
if stopErr != nil {
|
||||||
|
logger.ErrorContext(context.Background(), fmt.Sprintf("stop pinning proxy after failed start: %s", stopErr))
|
||||||
|
}
|
||||||
return fmt.Errorf("run exec allocator: %w", err)
|
return fmt.Errorf("run exec allocator: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,10 +336,10 @@ func (b *chromiumBrowser) Healthy(logger *slog.Logger) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
|
func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions, aggregate *networkAggregate) error {
|
||||||
// Note: no error wrapping because it leaks on errors we want to display to
|
// Note: no error wrapping because it leaks on errors we want to display to
|
||||||
// the end user.
|
// the end user.
|
||||||
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
|
return b.do(ctx, logger, url, options.Options, aggregate, chromedp.Tasks{
|
||||||
network.Enable(),
|
network.Enable(),
|
||||||
fetch.Enable(),
|
fetch.Enable(),
|
||||||
runtime.Enable(),
|
runtime.Enable(),
|
||||||
@@ -325,16 +356,16 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, out
|
|||||||
waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector),
|
waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector),
|
||||||
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
|
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
|
||||||
// PDF specific.
|
// PDF specific.
|
||||||
printToPdfActionFunc(logger, outputPath, options),
|
printToPdfActionFunc(ctx, logger, outputPath, options),
|
||||||
// Teardown.
|
// Teardown.
|
||||||
page.Close(),
|
page.Close(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
|
func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions, aggregate *networkAggregate) error {
|
||||||
// Note: no error wrapping because it leaks on errors we want to display to
|
// Note: no error wrapping because it leaks on errors we want to display to
|
||||||
// the end user.
|
// the end user.
|
||||||
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
|
return b.do(ctx, logger, url, options.Options, aggregate, chromedp.Tasks{
|
||||||
network.Enable(),
|
network.Enable(),
|
||||||
fetch.Enable(),
|
fetch.Enable(),
|
||||||
runtime.Enable(),
|
runtime.Enable(),
|
||||||
@@ -351,14 +382,14 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, u
|
|||||||
waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector),
|
waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector),
|
||||||
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
|
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
|
||||||
// Screenshot specific.
|
// Screenshot specific.
|
||||||
setDeviceMetricsOverride(logger, options.Width, options.Height),
|
setDeviceMetricsOverride(logger, options.Width, options.Height, options.DeviceScaleFactor),
|
||||||
captureScreenshotActionFunc(logger, outputPath, options),
|
captureScreenshotActionFunc(logger, outputPath, options),
|
||||||
// Teardown.
|
// Teardown.
|
||||||
page.Close(),
|
page.Close(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url string, options Options, tasks chromedp.Tasks) error {
|
func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url string, options Options, aggregate *networkAggregate, tasks chromedp.Tasks) error {
|
||||||
if !b.isStarted.Load() {
|
if !b.isStarted.Load() {
|
||||||
return errors.New("browser not started, cannot handle tasks")
|
return errors.New("browser not started, cannot handle tasks")
|
||||||
}
|
}
|
||||||
@@ -387,6 +418,9 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
|||||||
taskCtx, taskCancel := chromedp.NewContext(timeoutCtx)
|
taskCtx, taskCancel := chromedp.NewContext(timeoutCtx)
|
||||||
defer taskCancel()
|
defer taskCancel()
|
||||||
|
|
||||||
|
// Accumulate per-conversion network activity for telemetry.
|
||||||
|
listenForNetworkActivity(taskCtx, aggregate)
|
||||||
|
|
||||||
// We validate all other requests against our allowed / deny lists.
|
// We validate all other requests against our allowed / deny lists.
|
||||||
// If a request does not pass the validation, we make it fail. It also set
|
// If a request does not pass the validation, we make it fail. It also set
|
||||||
// the extra HTTP headers, if any.
|
// the extra HTTP headers, if any.
|
||||||
|
|||||||
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"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -101,12 +102,17 @@ type Chromium struct {
|
|||||||
supervisor gotenberg.ProcessSupervisor
|
supervisor gotenberg.ProcessSupervisor
|
||||||
engine gotenberg.PdfEngine
|
engine gotenberg.PdfEngine
|
||||||
|
|
||||||
|
version string
|
||||||
|
versionOnce sync.Once
|
||||||
|
|
||||||
reqsCounter metric.Int64Counter
|
reqsCounter metric.Int64Counter
|
||||||
errsCounter metric.Int64Counter
|
errsCounter metric.Int64Counter
|
||||||
conversionDurationCounter metric.Float64Histogram
|
conversionDurationCounter metric.Float64Histogram
|
||||||
queueWaitDurationCounter metric.Float64Histogram
|
queueWaitDurationCounter metric.Float64Histogram
|
||||||
pdfOutputSizeCounter metric.Int64Histogram
|
pdfOutputSizeCounter metric.Int64Histogram
|
||||||
imageOutputSizeCounter metric.Int64Histogram
|
imageOutputSizeCounter metric.Int64Histogram
|
||||||
|
networkRequestsCounter metric.Int64Counter
|
||||||
|
networkBytesCounter metric.Int64Histogram
|
||||||
}
|
}
|
||||||
|
|
||||||
// Options are the common options for all conversions.
|
// Options are the common options for all conversions.
|
||||||
@@ -294,7 +300,8 @@ type PdfOptions struct {
|
|||||||
PreferCssPageSize bool
|
PreferCssPageSize bool
|
||||||
|
|
||||||
// GenerateDocumentOutline defines whether the document outline should be
|
// 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
|
GenerateDocumentOutline bool
|
||||||
|
|
||||||
// GenerateTaggedPdf defines whether to generate tagged (accessible)
|
// GenerateTaggedPdf defines whether to generate tagged (accessible)
|
||||||
@@ -350,18 +357,23 @@ type ScreenshotOptions struct {
|
|||||||
// OptimizeForSpeed defines whether to optimize image encoding for speed,
|
// OptimizeForSpeed defines whether to optimize image encoding for speed,
|
||||||
// not for resulting size.
|
// not for resulting size.
|
||||||
OptimizeForSpeed bool
|
OptimizeForSpeed bool
|
||||||
|
|
||||||
|
// DeviceScaleFactor is the ratio of the resolution in physical pixels to
|
||||||
|
// the resolution in CSS pixels for the current display device.
|
||||||
|
DeviceScaleFactor float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultScreenshotOptions returns the default values for ScreenshotOptions.
|
// DefaultScreenshotOptions returns the default values for ScreenshotOptions.
|
||||||
func DefaultScreenshotOptions() ScreenshotOptions {
|
func DefaultScreenshotOptions() ScreenshotOptions {
|
||||||
return ScreenshotOptions{
|
return ScreenshotOptions{
|
||||||
Options: DefaultOptions(),
|
Options: DefaultOptions(),
|
||||||
Width: 800,
|
Width: 800,
|
||||||
Height: 600,
|
Height: 600,
|
||||||
Clip: false,
|
Clip: false,
|
||||||
Format: "png",
|
Format: "png",
|
||||||
Quality: 100,
|
Quality: 100,
|
||||||
OptimizeForSpeed: false,
|
OptimizeForSpeed: false,
|
||||||
|
DeviceScaleFactor: 1.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,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.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-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.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-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.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")
|
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")
|
||||||
@@ -479,12 +492,12 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
|||||||
|
|
||||||
binPath, ok := os.LookupEnv("CHROMIUM_BIN_PATH")
|
binPath, ok := os.LookupEnv("CHROMIUM_BIN_PATH")
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.New("CHROMIUM_BIN_PATH environment variable is not set")
|
return errors.New("CHROMIUM_BIN_PATH environment variable is not set; set it to the absolute path of the Chromium or Chrome binary")
|
||||||
}
|
}
|
||||||
|
|
||||||
hyphenDataDirPath, ok := os.LookupEnv("CHROMIUM_HYPHEN_DATA_DIR_PATH")
|
hyphenDataDirPath, ok := os.LookupEnv("CHROMIUM_HYPHEN_DATA_DIR_PATH")
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.New("CHROMIUM_HYPHEN_DATA_DIR_PATH environment variable is not set")
|
return errors.New("CHROMIUM_HYPHEN_DATA_DIR_PATH environment variable is not set; set it to the absolute path of the Chromium hyphenation data directory (it ships in the Gotenberg image)")
|
||||||
}
|
}
|
||||||
|
|
||||||
mod.args = browserArguments{
|
mod.args = browserArguments{
|
||||||
@@ -495,6 +508,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
|||||||
allowFileAccessFromFiles: flags.MustBool("chromium-allow-file-access-from-files"),
|
allowFileAccessFromFiles: flags.MustBool("chromium-allow-file-access-from-files"),
|
||||||
hostResolverRules: flags.MustString("chromium-host-resolver-rules"),
|
hostResolverRules: flags.MustString("chromium-host-resolver-rules"),
|
||||||
proxyServer: flags.MustString("chromium-proxy-server"),
|
proxyServer: flags.MustString("chromium-proxy-server"),
|
||||||
|
enableEnvironmentProxy: flags.MustBool("chromium-enable-environment-proxy"),
|
||||||
wsUrlReadTimeout: flags.MustDuration("chromium-start-timeout"),
|
wsUrlReadTimeout: flags.MustDuration("chromium-start-timeout"),
|
||||||
hyphenDataDirPath: hyphenDataDirPath,
|
hyphenDataDirPath: hyphenDataDirPath,
|
||||||
|
|
||||||
@@ -512,7 +526,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
|||||||
|
|
||||||
// Process.
|
// Process.
|
||||||
mod.browser = newChromiumBrowser(mod.args)
|
mod.browser = newChromiumBrowser(mod.args)
|
||||||
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"), mod.maxConcurrency, flags.MustDuration("chromium-idle-shutdown-timeout"))
|
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, "chromium", mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"), mod.maxConcurrency, flags.MustDuration("chromium-idle-shutdown-timeout"))
|
||||||
|
|
||||||
// PDF Engine.
|
// PDF Engine.
|
||||||
provider, err := ctx.Module(new(gotenberg.PdfEngineProvider))
|
provider, err := ctx.Module(new(gotenberg.PdfEngineProvider))
|
||||||
@@ -628,6 +642,24 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
|||||||
return fmt.Errorf("create chromium.image.output.size histogram: %w", err)
|
return fmt.Errorf("create chromium.image.output.size histogram: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod.networkRequestsCounter, err = meter.Int64Counter(
|
||||||
|
"chromium.network.requests.total",
|
||||||
|
metric.WithDescription("Total number of network requests made during Chromium conversions"),
|
||||||
|
metric.WithUnit("{request}"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create chromium.network.requests.total counter: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mod.networkBytesCounter, err = meter.Int64Histogram(
|
||||||
|
"chromium.network.bytes",
|
||||||
|
metric.WithDescription("Bytes fetched over the network during a Chromium conversion"),
|
||||||
|
metric.WithUnit("By"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create chromium.network.bytes histogram: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,12 +671,12 @@ func (mod *Chromium) Validate() error {
|
|||||||
|
|
||||||
_, err := os.Stat(mod.args.binPath)
|
_, err := os.Stat(mod.args.binPath)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return fmt.Errorf("chromium binary path does not exist: %w", err)
|
return fmt.Errorf("Chromium binary does not exist at %q; check the CHROMIUM_BIN_PATH environment variable: %w", mod.args.binPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = os.Stat(mod.args.hyphenDataDirPath)
|
_, err = os.Stat(mod.args.hyphenDataDirPath)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return fmt.Errorf("chromium hyphen-data directory path does not exist: %w", err)
|
return fmt.Errorf("Chromium hyphenation data directory does not exist at %q; check the CHROMIUM_HYPHEN_DATA_DIR_PATH environment variable (it ships in the Gotenberg image): %w", mod.args.hyphenDataDirPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -692,19 +724,46 @@ func (mod *Chromium) Stop(ctx context.Context) error {
|
|||||||
|
|
||||||
// Debug returns additional debug data.
|
// Debug returns additional debug data.
|
||||||
func (mod *Chromium) Debug() map[string]any {
|
func (mod *Chromium) Debug() map[string]any {
|
||||||
debug := make(map[string]any)
|
return map[string]any{"version": mod.detectVersion()}
|
||||||
|
}
|
||||||
|
|
||||||
cmd := exec.Command(mod.args.binPath, "--version") //nolint:gosec
|
// detectVersion resolves the Chromium version once, preferring the value
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
// captured at image build time so it never spawns Chromium at runtime. It falls
|
||||||
|
// back to running chromium --version for local or non-Docker builds.
|
||||||
|
func (mod *Chromium) detectVersion() string {
|
||||||
|
mod.versionOnce.Do(func() {
|
||||||
|
if v, ok := gotenberg.BuildVersion("chromium"); ok {
|
||||||
|
mod.version = v
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
output, err := cmd.Output()
|
cmd := exec.Command(mod.args.binPath, "--version") //nolint:gosec
|
||||||
if err != nil {
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
debug["version"] = err.Error()
|
|
||||||
return debug
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
mod.version = err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mod.version = strings.TrimSpace(string(output))
|
||||||
|
})
|
||||||
|
|
||||||
|
return mod.version
|
||||||
|
}
|
||||||
|
|
||||||
|
// spanAttrs returns the client-span attributes for a Chromium invocation: the
|
||||||
|
// server address and the Chromium version, plus any extra attributes. The
|
||||||
|
// version rides on every conversion span so a trace records which Chromium
|
||||||
|
// rendered the document.
|
||||||
|
func (mod *Chromium) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue {
|
||||||
|
attrs := make([]attribute.KeyValue, 0, 2+len(extra))
|
||||||
|
attrs = append(attrs, semconv.ServerAddress(mod.args.binPath))
|
||||||
|
if v := mod.detectVersion(); v != "" {
|
||||||
|
attrs = append(attrs, attribute.String("gotenberg.chromium.version", v))
|
||||||
}
|
}
|
||||||
|
|
||||||
debug["version"] = strings.TrimSpace(string(output))
|
return append(attrs, extra...)
|
||||||
return debug
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metrics returns the metrics.
|
// Metrics returns the metrics.
|
||||||
@@ -794,19 +853,32 @@ func (mod *Chromium) Routes() ([]api.Route, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pdf converts a URL to PDF.
|
// Pdf converts a URL to PDF.
|
||||||
|
//
|
||||||
|
//nolint:dupl
|
||||||
func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
|
func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
|
||||||
|
// Read input attributes before Start rebinds ctx to the span context, which
|
||||||
|
// would shadow the underlying [api.Context].
|
||||||
|
inputAttrs := conversionInputAttrs(ctx, url)
|
||||||
|
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Pdf",
|
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Pdf",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)),
|
trace.WithAttributes(mod.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
|
span.SetAttributes(inputAttrs...)
|
||||||
|
span.SetAttributes(
|
||||||
|
attribute.Int64("gotenberg.queue.depth_at_arrival", mod.supervisor.ReqQueueSize()),
|
||||||
|
attribute.Int64("gotenberg.conversions_since_last_restart", mod.supervisor.ConversionsSinceRestart()),
|
||||||
|
)
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
var conversionStart time.Time
|
var conversionStart time.Time
|
||||||
|
|
||||||
|
aggregate := newNetworkAggregate()
|
||||||
err := mod.supervisor.Run(ctx, logger, func() error {
|
err := mod.supervisor.Run(ctx, logger, func() error {
|
||||||
conversionStart = time.Now()
|
conversionStart = time.Now()
|
||||||
return mod.browser.pdf(ctx, logger, url, outputPath, options)
|
return mod.browser.pdf(ctx, logger, url, outputPath, options, aggregate)
|
||||||
})
|
})
|
||||||
|
|
||||||
end := time.Now()
|
end := time.Now()
|
||||||
@@ -819,21 +891,12 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
|
|||||||
status = "error"
|
status = "error"
|
||||||
}
|
}
|
||||||
|
|
||||||
reason := "unknown"
|
reason := chromiumErrorType(err, "chromium_unavailable")
|
||||||
switch {
|
|
||||||
case errors.Is(err, context.DeadlineExceeded):
|
|
||||||
reason = "timeout"
|
|
||||||
case errors.Is(err, context.Canceled):
|
|
||||||
reason = "context_cancelled"
|
|
||||||
case errors.Is(err, ErrInvalidHttpStatusCode) || errors.Is(err, ErrInvalidResourceHttpStatusCode) || errors.Is(err, ErrLoadingFailed) || errors.Is(err, ErrResourceLoadingFailed) || errors.Is(err, ErrInvalidEvaluationExpression) || errors.Is(err, ErrInvalidSelectorQuery):
|
|
||||||
reason = "invalid_input"
|
|
||||||
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) || errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
|
|
||||||
reason = "chromium_unavailable"
|
|
||||||
}
|
|
||||||
|
|
||||||
mod.errsCounter.Add(ctx, 1, metric.WithAttributes(
|
mod.errsCounter.Add(ctx, 1, metric.WithAttributes(
|
||||||
attribute.String("reason", reason),
|
attribute.String("reason", reason),
|
||||||
))
|
))
|
||||||
|
gotenberg.SpanErrorType(span, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !conversionStart.IsZero() {
|
if !conversionStart.IsZero() {
|
||||||
@@ -857,9 +920,12 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
|
|||||||
attribute.String("status", status),
|
attribute.String("status", status),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
mod.recordNetwork(ctx, span, aggregate)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
|
||||||
mod.pdfOutputSizeCounter.Record(ctx, fileInfo.Size())
|
mod.pdfOutputSizeCounter.Record(ctx, fileInfo.Size())
|
||||||
|
span.SetAttributes(attribute.Int64("gotenberg.conversion.output.bytes", fileInfo.Size()))
|
||||||
}
|
}
|
||||||
|
|
||||||
span.SetStatus(codes.Ok, "")
|
span.SetStatus(codes.Ok, "")
|
||||||
@@ -871,19 +937,28 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Screenshot captures a screenshot from a URL.
|
||||||
|
//
|
||||||
|
//nolint:dupl
|
||||||
func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
|
func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Screenshot",
|
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Screenshot",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)),
|
trace.WithAttributes(mod.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
|
span.SetAttributes(
|
||||||
|
attribute.Int64("gotenberg.queue.depth_at_arrival", mod.supervisor.ReqQueueSize()),
|
||||||
|
attribute.Int64("gotenberg.conversions_since_last_restart", mod.supervisor.ConversionsSinceRestart()),
|
||||||
|
)
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
var conversionStart time.Time
|
var conversionStart time.Time
|
||||||
|
|
||||||
|
aggregate := newNetworkAggregate()
|
||||||
err := mod.supervisor.Run(ctx, logger, func() error {
|
err := mod.supervisor.Run(ctx, logger, func() error {
|
||||||
conversionStart = time.Now()
|
conversionStart = time.Now()
|
||||||
return mod.browser.screenshot(ctx, logger, url, outputPath, options)
|
return mod.browser.screenshot(ctx, logger, url, outputPath, options, aggregate)
|
||||||
})
|
})
|
||||||
|
|
||||||
end := time.Now()
|
end := time.Now()
|
||||||
@@ -896,23 +971,12 @@ func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, o
|
|||||||
status = "error"
|
status = "error"
|
||||||
}
|
}
|
||||||
|
|
||||||
reason := "unknown"
|
reason := chromiumErrorType(err, "chromium_maximum_queue_size_exceeded")
|
||||||
switch {
|
|
||||||
case errors.Is(err, context.DeadlineExceeded):
|
|
||||||
reason = "timeout"
|
|
||||||
case errors.Is(err, context.Canceled):
|
|
||||||
reason = "context_cancelled"
|
|
||||||
case errors.Is(err, ErrInvalidHttpStatusCode) || errors.Is(err, ErrInvalidResourceHttpStatusCode) || errors.Is(err, ErrLoadingFailed) || errors.Is(err, ErrResourceLoadingFailed) || errors.Is(err, ErrInvalidEvaluationExpression) || errors.Is(err, ErrInvalidSelectorQuery):
|
|
||||||
reason = "invalid_input"
|
|
||||||
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded):
|
|
||||||
reason = "chromium_maximum_queue_size_exceeded"
|
|
||||||
case errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
|
|
||||||
reason = "chromium_unavailable"
|
|
||||||
}
|
|
||||||
|
|
||||||
mod.errsCounter.Add(ctx, 1, metric.WithAttributes(
|
mod.errsCounter.Add(ctx, 1, metric.WithAttributes(
|
||||||
attribute.String("reason", reason),
|
attribute.String("reason", reason),
|
||||||
))
|
))
|
||||||
|
gotenberg.SpanErrorType(span, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !conversionStart.IsZero() {
|
if !conversionStart.IsZero() {
|
||||||
@@ -936,6 +1000,8 @@ func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, o
|
|||||||
attribute.String("status", status),
|
attribute.String("status", status),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
mod.recordNetwork(ctx, span, aggregate)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
|
||||||
mod.imageOutputSizeCounter.Record(ctx, fileInfo.Size())
|
mod.imageOutputSizeCounter.Record(ctx, fileInfo.Size())
|
||||||
@@ -950,6 +1016,86 @@ func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, o
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recordNetwork lifts per-conversion network aggregates onto the span and the
|
||||||
|
// network metrics. Counts are dimensioned by outcome and bytes feed a
|
||||||
|
// histogram; both are recorded with the conversion context so the SDK attaches
|
||||||
|
// trace exemplars. The heaviest resource URL is redacted before it lands on the
|
||||||
|
// span event.
|
||||||
|
func (mod *Chromium) recordNetwork(ctx context.Context, span trace.Span, aggregate *networkAggregate) {
|
||||||
|
if aggregate == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := aggregate.snapshot()
|
||||||
|
|
||||||
|
span.SetAttributes(
|
||||||
|
attribute.Int64("gotenberg.chromium.resources.count", stats.requestCount),
|
||||||
|
attribute.Int64("gotenberg.chromium.resources.bytes_total", stats.bytesTotal),
|
||||||
|
attribute.Int64("gotenberg.chromium.resources.failed_count", stats.failedCount),
|
||||||
|
attribute.Int64("gotenberg.chromium.resources.unique_origins", stats.uniqueOrigins),
|
||||||
|
)
|
||||||
|
|
||||||
|
if stats.heaviestURL != "" {
|
||||||
|
span.AddEvent("chromium.heaviest_resource", trace.WithAttributes(
|
||||||
|
attribute.String("url", gotenberg.RedactURL(stats.heaviestURL)),
|
||||||
|
attribute.Int64("bytes", stats.heaviestBytes),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := stats.requestCount - stats.failedCount; ok > 0 {
|
||||||
|
mod.networkRequestsCounter.Add(ctx, ok, metric.WithAttributes(attribute.String("outcome", "ok")))
|
||||||
|
}
|
||||||
|
if stats.failedCount > 0 {
|
||||||
|
mod.networkRequestsCounter.Add(ctx, stats.failedCount, metric.WithAttributes(attribute.String("outcome", "failed")))
|
||||||
|
}
|
||||||
|
|
||||||
|
mod.networkBytesCounter.Record(ctx, stats.bytesTotal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// conversionInputAttrs derives low-cardinality input attributes for a
|
||||||
|
// conversion span: the number of received files (when ctx is an [api.Context])
|
||||||
|
// and the size of the local HTML input (when url is a file:// URL). Remote URL
|
||||||
|
// conversions yield no html.bytes.
|
||||||
|
func conversionInputAttrs(ctx context.Context, url string) []attribute.KeyValue {
|
||||||
|
var attrs []attribute.KeyValue
|
||||||
|
|
||||||
|
if apiCtx, ok := ctx.(*api.Context); ok {
|
||||||
|
attrs = append(attrs, attribute.Int("gotenberg.conversion.input.files.count", apiCtx.FileCount()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if after, ok := strings.CutPrefix(url, "file://"); ok {
|
||||||
|
if info, err := os.Stat(after); err == nil {
|
||||||
|
attrs = append(attrs, attribute.Int64("gotenberg.conversion.input.html.bytes", info.Size()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
}
|
||||||
|
|
||||||
|
// chromiumErrorType maps a conversion error to chromium's bounded reason value,
|
||||||
|
// reused as the span error.type. queueReason preserves the historical,
|
||||||
|
// route-specific label for a saturated queue: Pdf collapses it into
|
||||||
|
// "chromium_unavailable", whereas Screenshot keeps
|
||||||
|
// "chromium_maximum_queue_size_exceeded". Generic failures fall back to
|
||||||
|
// [gotenberg.ClassifyError].
|
||||||
|
func chromiumErrorType(err error, queueReason string) string {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrInvalidHttpStatusCode),
|
||||||
|
errors.Is(err, ErrInvalidResourceHttpStatusCode),
|
||||||
|
errors.Is(err, ErrLoadingFailed),
|
||||||
|
errors.Is(err, ErrResourceLoadingFailed),
|
||||||
|
errors.Is(err, ErrInvalidEvaluationExpression),
|
||||||
|
errors.Is(err, ErrInvalidSelectorQuery):
|
||||||
|
return gotenberg.ErrorTypeInvalidInput
|
||||||
|
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded):
|
||||||
|
return queueReason
|
||||||
|
case errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
|
||||||
|
return "chromium_unavailable"
|
||||||
|
default:
|
||||||
|
return gotenberg.ClassifyError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Interface guards.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ gotenberg.Module = (*Chromium)(nil)
|
_ gotenberg.Module = (*Chromium)(nil)
|
||||||
|
|||||||
37
pkg/modules/chromium/errortype_test.go
Normal file
37
pkg/modules/chromium/errortype_test.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package chromium
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChromiumErrorType(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
queueReason string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"deadline", context.DeadlineExceeded, "chromium_unavailable", "timeout"},
|
||||||
|
{"canceled", context.Canceled, "chromium_unavailable", "context_cancelled"},
|
||||||
|
{"invalid http status", ErrInvalidHttpStatusCode, "chromium_unavailable", "invalid_input"},
|
||||||
|
{"invalid resource http status", ErrInvalidResourceHttpStatusCode, "chromium_unavailable", "invalid_input"},
|
||||||
|
{"loading failed", ErrLoadingFailed, "chromium_unavailable", "invalid_input"},
|
||||||
|
{"resource loading failed", ErrResourceLoadingFailed, "chromium_unavailable", "invalid_input"},
|
||||||
|
{"invalid evaluation expression", ErrInvalidEvaluationExpression, "chromium_unavailable", "invalid_input"},
|
||||||
|
{"invalid selector query", ErrInvalidSelectorQuery, "chromium_unavailable", "invalid_input"},
|
||||||
|
{"pdf queue", gotenberg.ErrMaximumQueueSizeExceeded, "chromium_unavailable", "chromium_unavailable"},
|
||||||
|
{"screenshot queue", gotenberg.ErrMaximumQueueSizeExceeded, "chromium_maximum_queue_size_exceeded", "chromium_maximum_queue_size_exceeded"},
|
||||||
|
{"restarting", gotenberg.ErrProcessAlreadyRestarting, "chromium_maximum_queue_size_exceeded", "chromium_unavailable"},
|
||||||
|
{"unknown", errors.New("boom"), "chromium_unavailable", "unknown"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := chromiumErrorType(tc.err, tc.queueReason); got != tc.want {
|
||||||
|
t.Errorf("chromiumErrorType(%v, %q) = %q, want %q", tc.err, tc.queueReason, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,26 @@ import (
|
|||||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// listenForNetworkActivity accumulates per-conversion network activity into
|
||||||
|
// aggregate from the always-on Network domain events. It is a no-op when
|
||||||
|
// aggregate is nil.
|
||||||
|
func listenForNetworkActivity(ctx context.Context, aggregate *networkAggregate) {
|
||||||
|
if aggregate == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chromedp.ListenTarget(ctx, func(ev any) {
|
||||||
|
switch e := ev.(type) {
|
||||||
|
case *network.EventResponseReceived:
|
||||||
|
aggregate.onResponseReceived(e)
|
||||||
|
case *network.EventLoadingFinished:
|
||||||
|
aggregate.onLoadingFinished(e)
|
||||||
|
case *network.EventLoadingFailed:
|
||||||
|
aggregate.onLoadingFailed(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type eventRequestPausedOptions struct {
|
type eventRequestPausedOptions struct {
|
||||||
allowList, denyList []*regexp2.Regexp
|
allowList, denyList []*regexp2.Regexp
|
||||||
denyPrivateIPs bool
|
denyPrivateIPs bool
|
||||||
@@ -428,19 +448,24 @@ func listenForEventExceptionThrown(ctx context.Context, logger *slog.Logger, con
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitForEventDomContentEventFired waits until the event DomContentEventFired
|
// waitForEventDomContentEventFired registers a listener for the
|
||||||
// is fired or the context timeout.
|
// DomContentEventFired event and returns a waiter that blocks until the
|
||||||
|
// event fires or ctx is done. The listener registers at call time, not
|
||||||
|
// inside the waiter, so callers must invoke this before triggering the
|
||||||
|
// action that may emit the event. Registering inside the waiter would
|
||||||
|
// open a race: for fast loads (typically file:// pages with no external
|
||||||
|
// sub-resources), the event can fire before the waiter goroutine starts
|
||||||
|
// and the listener never sees a record.
|
||||||
func waitForEventDomContentEventFired(ctx context.Context, logger *slog.Logger) func() error {
|
func waitForEventDomContentEventFired(ctx context.Context, logger *slog.Logger) func() error {
|
||||||
|
ch := make(chan struct{})
|
||||||
|
cctx, cancel := context.WithCancel(ctx)
|
||||||
|
chromedp.ListenTarget(cctx, func(ev any) {
|
||||||
|
if _, ok := ev.(*page.EventDomContentEventFired); ok {
|
||||||
|
cancel()
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
})
|
||||||
return func() error {
|
return func() error {
|
||||||
ch := make(chan struct{})
|
|
||||||
cctx, cancel := context.WithCancel(ctx)
|
|
||||||
chromedp.ListenTarget(cctx, func(ev any) {
|
|
||||||
if _, ok := ev.(*page.EventDomContentEventFired); ok {
|
|
||||||
cancel()
|
|
||||||
close(ch)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ch:
|
case <-ch:
|
||||||
logger.DebugContext(ctx, "event DomContentEventFired fired")
|
logger.DebugContext(ctx, "event DomContentEventFired fired")
|
||||||
@@ -451,19 +476,20 @@ func waitForEventDomContentEventFired(ctx context.Context, logger *slog.Logger)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitForEventLoadEventFired waits until the event LoadEventFired is fired or
|
// waitForEventLoadEventFired registers a listener for the LoadEventFired
|
||||||
// the context timeout.
|
// event and returns a waiter that blocks until the event fires or ctx is
|
||||||
|
// done. See [waitForEventDomContentEventFired] for the rationale on
|
||||||
|
// registering at call time rather than inside the waiter.
|
||||||
func waitForEventLoadEventFired(ctx context.Context, logger *slog.Logger) func() error {
|
func waitForEventLoadEventFired(ctx context.Context, logger *slog.Logger) func() error {
|
||||||
|
ch := make(chan struct{})
|
||||||
|
cctx, cancel := context.WithCancel(ctx)
|
||||||
|
chromedp.ListenTarget(cctx, func(ev any) {
|
||||||
|
if _, ok := ev.(*page.EventLoadEventFired); ok {
|
||||||
|
cancel()
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
})
|
||||||
return func() error {
|
return func() error {
|
||||||
ch := make(chan struct{})
|
|
||||||
cctx, cancel := context.WithCancel(ctx)
|
|
||||||
chromedp.ListenTarget(cctx, func(ev any) {
|
|
||||||
if _, ok := ev.(*page.EventLoadEventFired); ok {
|
|
||||||
cancel()
|
|
||||||
close(ch)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ch:
|
case <-ch:
|
||||||
logger.DebugContext(ctx, "event LoadEventFired fired")
|
logger.DebugContext(ctx, "event LoadEventFired fired")
|
||||||
@@ -474,19 +500,20 @@ func waitForEventLoadEventFired(ctx context.Context, logger *slog.Logger) func()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitForEventNetworkIdle waits until the event networkIdle is fired or the
|
// waitForEventNetworkIdle registers a listener for the networkIdle
|
||||||
// context timeout.
|
// lifecycle event and returns a waiter that blocks until the event fires
|
||||||
|
// or ctx is done. See [waitForEventDomContentEventFired] for the
|
||||||
|
// rationale on registering at call time rather than inside the waiter.
|
||||||
func waitForEventNetworkIdle(ctx context.Context, logger *slog.Logger) func() error {
|
func waitForEventNetworkIdle(ctx context.Context, logger *slog.Logger) func() error {
|
||||||
|
ch := make(chan struct{})
|
||||||
|
cctx, cancel := context.WithCancel(ctx)
|
||||||
|
chromedp.ListenTarget(cctx, func(ev any) {
|
||||||
|
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle" {
|
||||||
|
cancel()
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
})
|
||||||
return func() error {
|
return func() error {
|
||||||
ch := make(chan struct{})
|
|
||||||
cctx, cancel := context.WithCancel(ctx)
|
|
||||||
chromedp.ListenTarget(cctx, func(ev any) {
|
|
||||||
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle" {
|
|
||||||
cancel()
|
|
||||||
close(ch)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ch:
|
case <-ch:
|
||||||
logger.DebugContext(ctx, "event networkIdle fired")
|
logger.DebugContext(ctx, "event networkIdle fired")
|
||||||
@@ -497,19 +524,20 @@ func waitForEventNetworkIdle(ctx context.Context, logger *slog.Logger) func() er
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitForEventNetworkAlmostIdle waits until the event networkIdle2 is fired
|
// waitForEventNetworkAlmostIdle registers a listener for the networkIdle2
|
||||||
// or the context timeout.
|
// lifecycle event and returns a waiter that blocks until the event fires
|
||||||
|
// or ctx is done. See [waitForEventDomContentEventFired] for the
|
||||||
|
// rationale on registering at call time rather than inside the waiter.
|
||||||
func waitForEventNetworkAlmostIdle(ctx context.Context, logger *slog.Logger) func() error {
|
func waitForEventNetworkAlmostIdle(ctx context.Context, logger *slog.Logger) func() error {
|
||||||
|
ch := make(chan struct{})
|
||||||
|
cctx, cancel := context.WithCancel(ctx)
|
||||||
|
chromedp.ListenTarget(cctx, func(ev any) {
|
||||||
|
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle2" {
|
||||||
|
cancel()
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
})
|
||||||
return func() error {
|
return func() error {
|
||||||
ch := make(chan struct{})
|
|
||||||
cctx, cancel := context.WithCancel(ctx)
|
|
||||||
chromedp.ListenTarget(cctx, func(ev any) {
|
|
||||||
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle2" {
|
|
||||||
cancel()
|
|
||||||
close(ch)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ch:
|
case <-ch:
|
||||||
logger.DebugContext(ctx, "event networkAlmostIdle fired")
|
logger.DebugContext(ctx, "event networkAlmostIdle fired")
|
||||||
@@ -520,19 +548,20 @@ func waitForEventNetworkAlmostIdle(ctx context.Context, logger *slog.Logger) fun
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitForEventLoadingFinished waits until the event LoadingFinished is fired
|
// waitForEventLoadingFinished registers a listener for the
|
||||||
// or the context timeout.
|
// LoadingFinished event and returns a waiter that blocks until the event
|
||||||
|
// fires or ctx is done. See [waitForEventDomContentEventFired] for the
|
||||||
|
// rationale on registering at call time rather than inside the waiter.
|
||||||
func waitForEventLoadingFinished(ctx context.Context, logger *slog.Logger) func() error {
|
func waitForEventLoadingFinished(ctx context.Context, logger *slog.Logger) func() error {
|
||||||
|
ch := make(chan struct{})
|
||||||
|
cctx, cancel := context.WithCancel(ctx)
|
||||||
|
chromedp.ListenTarget(cctx, func(ev any) {
|
||||||
|
if _, ok := ev.(*network.EventLoadingFinished); ok {
|
||||||
|
cancel()
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
})
|
||||||
return func() error {
|
return func() error {
|
||||||
ch := make(chan struct{})
|
|
||||||
cctx, cancel := context.WithCancel(ctx)
|
|
||||||
chromedp.ListenTarget(cctx, func(ev any) {
|
|
||||||
if _, ok := ev.(*network.EventLoadingFinished); ok {
|
|
||||||
cancel()
|
|
||||||
close(ch)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ch:
|
case <-ch:
|
||||||
logger.DebugContext(ctx, "event LoadingFinished fired")
|
logger.DebugContext(ctx, "event LoadingFinished fired")
|
||||||
|
|||||||
@@ -24,16 +24,16 @@ func (api *ApiMock) Screenshot(ctx context.Context, logger *slog.Logger, url, ou
|
|||||||
// browserMock is a mock for the [browser] interface.
|
// browserMock is a mock for the [browser] interface.
|
||||||
type browserMock struct {
|
type browserMock struct {
|
||||||
gotenberg.ProcessMock
|
gotenberg.ProcessMock
|
||||||
pdfMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error
|
pdfMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions, aggregate *networkAggregate) error
|
||||||
screenshotMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error
|
screenshotMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions, aggregate *networkAggregate) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *browserMock) pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
|
func (b *browserMock) pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions, aggregate *networkAggregate) error {
|
||||||
return b.pdfMock(ctx, logger, url, outputPath, options)
|
return b.pdfMock(ctx, logger, url, outputPath, options, aggregate)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *browserMock) screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
|
func (b *browserMock) screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions, aggregate *networkAggregate) error {
|
||||||
return b.screenshotMock(ctx, logger, url, outputPath, options)
|
return b.screenshotMock(ctx, logger, url, outputPath, options, aggregate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interface guards.
|
// Interface guards.
|
||||||
|
|||||||
121
pkg/modules/chromium/network_aggregate.go
Normal file
121
pkg/modules/chromium/network_aggregate.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package chromium
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/chromedp/cdproto/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxTrackedOrigins bounds the distinct origins kept per conversion so a
|
||||||
|
// pathological page cannot grow the set without limit.
|
||||||
|
const maxTrackedOrigins = 64
|
||||||
|
|
||||||
|
// networkAggregate accumulates per-conversion network activity from Chromium
|
||||||
|
// DevTools events. It is safe for concurrent use by the chromedp event listener
|
||||||
|
// goroutine and the conversion goroutine that reads the snapshot afterwards.
|
||||||
|
type networkAggregate struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
requestCount int64
|
||||||
|
bytesTotal int64
|
||||||
|
failedCount int64
|
||||||
|
|
||||||
|
origins map[string]struct{}
|
||||||
|
requestURLByID map[network.RequestID]string
|
||||||
|
|
||||||
|
heaviestURL string
|
||||||
|
heaviestBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// networkStats is an immutable snapshot of a [networkAggregate].
|
||||||
|
type networkStats struct {
|
||||||
|
requestCount int64
|
||||||
|
bytesTotal int64
|
||||||
|
failedCount int64
|
||||||
|
uniqueOrigins int64
|
||||||
|
heaviestURL string
|
||||||
|
heaviestBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newNetworkAggregate() *networkAggregate {
|
||||||
|
return &networkAggregate{
|
||||||
|
origins: make(map[string]struct{}),
|
||||||
|
requestURLByID: make(map[network.RequestID]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// onResponseReceived records the response origin and remembers the URL for the
|
||||||
|
// request id, so a later loading-finished event can attribute its bytes.
|
||||||
|
func (a *networkAggregate) onResponseReceived(ev *network.EventResponseReceived) {
|
||||||
|
if ev == nil || ev.Response == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
if origin := originOf(ev.Response.URL); origin != "" {
|
||||||
|
if _, ok := a.origins[origin]; !ok && len(a.origins) < maxTrackedOrigins {
|
||||||
|
a.origins[origin] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.requestURLByID[ev.RequestID] = ev.Response.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// onLoadingFinished records a successfully completed request and its size,
|
||||||
|
// tracking the single heaviest resource.
|
||||||
|
func (a *networkAggregate) onLoadingFinished(ev *network.EventLoadingFinished) {
|
||||||
|
if ev == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
a.requestCount++
|
||||||
|
|
||||||
|
size := int64(ev.EncodedDataLength)
|
||||||
|
a.bytesTotal += size
|
||||||
|
if size > a.heaviestBytes {
|
||||||
|
a.heaviestBytes = size
|
||||||
|
a.heaviestURL = a.requestURLByID[ev.RequestID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// onLoadingFailed records a request that failed to complete.
|
||||||
|
func (a *networkAggregate) onLoadingFailed(ev *network.EventLoadingFailed) {
|
||||||
|
if ev == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
a.requestCount++
|
||||||
|
a.failedCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *networkAggregate) snapshot() networkStats {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
return networkStats{
|
||||||
|
requestCount: a.requestCount,
|
||||||
|
bytesTotal: a.bytesTotal,
|
||||||
|
failedCount: a.failedCount,
|
||||||
|
uniqueOrigins: int64(len(a.origins)),
|
||||||
|
heaviestURL: a.heaviestURL,
|
||||||
|
heaviestBytes: a.heaviestBytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// originOf returns the scheme://host of rawURL, or an empty string when it has
|
||||||
|
// no host (for example data: or file: URLs).
|
||||||
|
func originOf(rawURL string) string {
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil || parsed.Host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parsed.Scheme + "://" + parsed.Host
|
||||||
|
}
|
||||||
100
pkg/modules/chromium/network_aggregate_test.go
Normal file
100
pkg/modules/chromium/network_aggregate_test.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package chromium
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/chromedp/cdproto/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOriginOf(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
raw string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"https://example.com/path?q=1", "https://example.com"},
|
||||||
|
{"http://cdn.example.com:8080/a.js", "http://cdn.example.com:8080"},
|
||||||
|
{"data:image/png;base64,AAAA", ""},
|
||||||
|
{"file:///tmp/index.html", ""},
|
||||||
|
{"not a url", ""},
|
||||||
|
} {
|
||||||
|
if got := originOf(tc.raw); got != tc.want {
|
||||||
|
t.Errorf("originOf(%q) = %q, want %q", tc.raw, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNetworkAggregate_Snapshot(t *testing.T) {
|
||||||
|
a := newNetworkAggregate()
|
||||||
|
|
||||||
|
a.onResponseReceived(&network.EventResponseReceived{
|
||||||
|
RequestID: "1",
|
||||||
|
Response: &network.Response{URL: "https://example.com/a.js"},
|
||||||
|
})
|
||||||
|
a.onResponseReceived(&network.EventResponseReceived{
|
||||||
|
RequestID: "2",
|
||||||
|
Response: &network.Response{URL: "https://cdn.example.com/b.png"},
|
||||||
|
})
|
||||||
|
// Duplicate origin must not grow the set.
|
||||||
|
a.onResponseReceived(&network.EventResponseReceived{
|
||||||
|
RequestID: "3",
|
||||||
|
Response: &network.Response{URL: "https://example.com/c.css"},
|
||||||
|
})
|
||||||
|
|
||||||
|
a.onLoadingFinished(&network.EventLoadingFinished{RequestID: "1", EncodedDataLength: 100})
|
||||||
|
a.onLoadingFinished(&network.EventLoadingFinished{RequestID: "2", EncodedDataLength: 900})
|
||||||
|
a.onLoadingFailed(&network.EventLoadingFailed{RequestID: "3"})
|
||||||
|
|
||||||
|
got := a.snapshot()
|
||||||
|
if got.requestCount != 3 {
|
||||||
|
t.Errorf("requestCount = %d, want 3", got.requestCount)
|
||||||
|
}
|
||||||
|
if got.bytesTotal != 1000 {
|
||||||
|
t.Errorf("bytesTotal = %d, want 1000", got.bytesTotal)
|
||||||
|
}
|
||||||
|
if got.failedCount != 1 {
|
||||||
|
t.Errorf("failedCount = %d, want 1", got.failedCount)
|
||||||
|
}
|
||||||
|
if got.uniqueOrigins != 2 {
|
||||||
|
t.Errorf("uniqueOrigins = %d, want 2", got.uniqueOrigins)
|
||||||
|
}
|
||||||
|
if got.heaviestBytes != 900 || got.heaviestURL != "https://cdn.example.com/b.png" {
|
||||||
|
t.Errorf("heaviest = (%q, %d), want (%q, 900)", got.heaviestURL, got.heaviestBytes, "https://cdn.example.com/b.png")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNetworkAggregate_OriginCap(t *testing.T) {
|
||||||
|
a := newNetworkAggregate()
|
||||||
|
for i := range maxTrackedOrigins + 50 {
|
||||||
|
a.onResponseReceived(&network.EventResponseReceived{
|
||||||
|
RequestID: network.RequestID(fmt.Sprintf("r%d", i)),
|
||||||
|
Response: &network.Response{URL: fmt.Sprintf("https://host%d.example.com/x", i)},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if got := a.snapshot().uniqueOrigins; got != maxTrackedOrigins {
|
||||||
|
t.Errorf("uniqueOrigins = %d, want %d (capped)", got, maxTrackedOrigins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNetworkAggregate_ConcurrentSafe(t *testing.T) {
|
||||||
|
a := newNetworkAggregate()
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := range 100 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
id := network.RequestID(fmt.Sprintf("r%d", i))
|
||||||
|
a.onResponseReceived(&network.EventResponseReceived{
|
||||||
|
RequestID: id,
|
||||||
|
Response: &network.Response{URL: fmt.Sprintf("https://host%d.example.com/x", i)},
|
||||||
|
})
|
||||||
|
a.onLoadingFinished(&network.EventLoadingFinished{RequestID: id, EncodedDataLength: 10})
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if got := a.snapshot().requestCount; got != 100 {
|
||||||
|
t.Errorf("requestCount = %d, want 100", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,10 +9,12 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dlclark/regexp2"
|
"github.com/dlclark/regexp2"
|
||||||
|
"golang.org/x/net/http/httpproxy"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
@@ -43,6 +45,14 @@ type pinningProxy struct {
|
|||||||
// allow-list opt-in). Tests may override it.
|
// allow-list opt-in). Tests may override it.
|
||||||
dialBypass func(ctx context.Context, network, addr string) (net.Conn, error)
|
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
|
listener net.Listener
|
||||||
server *http.Server
|
server *http.Server
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
@@ -57,8 +67,8 @@ type pinningProxy struct {
|
|||||||
// [gotenberg.DecideOutbound] on every request the proxy sees, so
|
// [gotenberg.DecideOutbound] on every request the proxy sees, so
|
||||||
// Chromium inherits whatever posture the operator selected. The
|
// Chromium inherits whatever posture the operator selected. The
|
||||||
// returned proxy is not yet listening; call Start.
|
// returned proxy is not yet listening; call Start.
|
||||||
func newPinningProxy(allowList, denyList []*regexp2.Regexp, denyPrivateIPs, denyPublicIPs bool) *pinningProxy {
|
func newPinningProxy(allowList, denyList []*regexp2.Regexp, denyPrivateIPs, denyPublicIPs, enableEnvironmentProxy bool) *pinningProxy {
|
||||||
return &pinningProxy{
|
p := &pinningProxy{
|
||||||
allowList: allowList,
|
allowList: allowList,
|
||||||
denyList: denyList,
|
denyList: denyList,
|
||||||
decide: func(ctx context.Context, rawURL string, allow, deny []*regexp2.Regexp, deadline time.Time) (gotenberg.OutboundDecision, error) {
|
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)
|
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
|
// Start binds the proxy to 127.0.0.1 on an ephemeral port and serves in a
|
||||||
@@ -179,13 +197,33 @@ func (p *pinningProxy) handleConnect(w http.ResponseWriter, req *http.Request) {
|
|||||||
// the CONNECT handling beyond filtering.
|
// the CONNECT handling beyond filtering.
|
||||||
decision, err := p.decide(req.Context(), "https://"+req.Host, p.allowList, p.denyList, deadline)
|
decision, err := p.decide(req.Context(), "https://"+req.Host, p.allowList, p.denyList, deadline)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.logger.WarnContext(req.Context(), fmt.Sprintf("CONNECT blocked for '%s': %s", req.Host, err))
|
if isClientCancellation(req.Context(), err) {
|
||||||
|
p.logger.DebugContext(req.Context(), fmt.Sprintf("CONNECT abandoned by client for '%s': %s", req.Host, err))
|
||||||
|
} else {
|
||||||
|
p.logger.WarnContext(req.Context(), fmt.Sprintf("CONNECT blocked for '%s': %s", req.Host, err))
|
||||||
|
}
|
||||||
http.Error(w, "CONNECT blocked", http.StatusForbidden)
|
http.Error(w, "CONNECT blocked", http.StatusForbidden)
|
||||||
return
|
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
|
var upstream net.Conn
|
||||||
switch {
|
switch {
|
||||||
|
case proxyURL != nil:
|
||||||
|
upstream, err = p.dialThroughUpstreamProxy(req.Context(), proxyURL, req.Host)
|
||||||
case decision.Bypass:
|
case decision.Bypass:
|
||||||
upstream, err = p.dialBypass(req.Context(), "tcp", req.Host)
|
upstream, err = p.dialBypass(req.Context(), "tcp", req.Host)
|
||||||
case len(decision.Pinned) > 0:
|
case len(decision.Pinned) > 0:
|
||||||
@@ -194,7 +232,11 @@ func (p *pinningProxy) handleConnect(w http.ResponseWriter, req *http.Request) {
|
|||||||
err = errors.New("no pinned addresses and not bypassed")
|
err = errors.New("no pinned addresses and not bypassed")
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.logger.WarnContext(req.Context(), fmt.Sprintf("CONNECT dial failed for '%s': %s", req.Host, err))
|
if isClientCancellation(req.Context(), err) {
|
||||||
|
p.logger.DebugContext(req.Context(), fmt.Sprintf("CONNECT dial abandoned by client for '%s': %s", req.Host, err))
|
||||||
|
} else {
|
||||||
|
p.logger.WarnContext(req.Context(), fmt.Sprintf("CONNECT dial failed for '%s': %s", req.Host, err))
|
||||||
|
}
|
||||||
http.Error(w, "upstream dial failed", http.StatusBadGateway)
|
http.Error(w, "upstream dial failed", http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -214,7 +256,11 @@ func (p *pinningProxy) handleConnect(w http.ResponseWriter, req *http.Request) {
|
|||||||
|
|
||||||
_, err = client.Write([]byte("HTTP/1.1 200 OK\r\n\r\n"))
|
_, err = client.Write([]byte("HTTP/1.1 200 OK\r\n\r\n"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.logger.WarnContext(req.Context(), fmt.Sprintf("write CONNECT ack: %s", err))
|
if isClientCancellation(req.Context(), err) {
|
||||||
|
p.logger.DebugContext(req.Context(), fmt.Sprintf("write CONNECT ack abandoned by client: %s", err))
|
||||||
|
} else {
|
||||||
|
p.logger.WarnContext(req.Context(), fmt.Sprintf("write CONNECT ack: %s", err))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,22 +300,44 @@ func (p *pinningProxy) handleForward(w http.ResponseWriter, req *http.Request) {
|
|||||||
|
|
||||||
decision, err := p.decide(req.Context(), req.URL.String(), p.allowList, p.denyList, deadline)
|
decision, err := p.decide(req.Context(), req.URL.String(), p.allowList, p.denyList, deadline)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.logger.WarnContext(req.Context(), fmt.Sprintf("forward blocked for '%s': %s", req.URL, err))
|
if isClientCancellation(req.Context(), err) {
|
||||||
|
p.logger.DebugContext(req.Context(), fmt.Sprintf("forward abandoned by client for '%s': %s", req.URL, err))
|
||||||
|
} else {
|
||||||
|
p.logger.WarnContext(req.Context(), fmt.Sprintf("forward blocked for '%s': %s", req.URL, err))
|
||||||
|
}
|
||||||
http.Error(w, "request blocked", http.StatusForbidden)
|
http.Error(w, "request blocked", http.StatusForbidden)
|
||||||
return
|
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 := req.Clone(req.Context())
|
||||||
outReq.RequestURI = ""
|
outReq.RequestURI = ""
|
||||||
stripHopByHopHeaders(outReq.Header)
|
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{
|
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,
|
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)
|
_, port, splitErr := net.SplitHostPort(addr)
|
||||||
if splitErr != nil {
|
if splitErr != nil {
|
||||||
return nil, fmt.Errorf("split forward addr %q: %w", addr, splitErr)
|
return nil, fmt.Errorf("split forward addr %q: %w", addr, splitErr)
|
||||||
@@ -282,13 +350,17 @@ func (p *pinningProxy) handleForward(w http.ResponseWriter, req *http.Request) {
|
|||||||
default:
|
default:
|
||||||
return nil, errors.New("no pinned addresses and not bypassed")
|
return nil, errors.New("no pinned addresses and not bypassed")
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
defer transport.CloseIdleConnections()
|
defer transport.CloseIdleConnections()
|
||||||
|
|
||||||
resp, err := transport.RoundTrip(outReq)
|
resp, err := transport.RoundTrip(outReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.logger.WarnContext(req.Context(), fmt.Sprintf("forward RoundTrip failed for '%s': %s", req.URL, err))
|
if isClientCancellation(req.Context(), err) {
|
||||||
|
p.logger.DebugContext(req.Context(), fmt.Sprintf("forward RoundTrip abandoned by client for '%s': %s", req.URL, err))
|
||||||
|
} else {
|
||||||
|
p.logger.WarnContext(req.Context(), fmt.Sprintf("forward RoundTrip failed for '%s': %s", req.URL, err))
|
||||||
|
}
|
||||||
http.Error(w, "upstream error", http.StatusBadGateway)
|
http.Error(w, "upstream error", http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -326,3 +398,29 @@ func copyHeaders(dst, src http.Header) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isClientCancellation reports whether err originates from the client (for
|
||||||
|
// example Chromium) closing the connection or letting the request deadline
|
||||||
|
// pass before the proxy could finish validating the destination. Such
|
||||||
|
// errors are not policy refusals: the proxy never reached an allow/deny
|
||||||
|
// rule decision. Callers downgrade these to debug to avoid alarming
|
||||||
|
// operators with noise from speculative or aborted browser requests. The
|
||||||
|
// canonical case is a Chromium DNS prefetch that the browser drops before
|
||||||
|
// the proxy's [outbound.resolveHost] call returns. The [net.DNSError]
|
||||||
|
// returned by [net.Resolver.LookupNetIP] unwraps to [context.Canceled] or
|
||||||
|
// [context.DeadlineExceeded] in that case, so an [errors.Is] walk catches
|
||||||
|
// it.
|
||||||
|
func isClientCancellation(ctx context.Context, err error) bool {
|
||||||
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -22,6 +23,33 @@ import (
|
|||||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// recordingHandler is a slog.Handler that captures every record emitted
|
||||||
|
// through it so tests can assert on the level and message of proxy logs.
|
||||||
|
type recordingHandler struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
records []slog.Record
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *recordingHandler) Enabled(_ context.Context, _ slog.Level) bool { return true }
|
||||||
|
|
||||||
|
func (h *recordingHandler) Handle(_ context.Context, r slog.Record) error {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
h.records = append(h.records, r.Clone())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *recordingHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h }
|
||||||
|
func (h *recordingHandler) WithGroup(_ string) slog.Handler { return h }
|
||||||
|
|
||||||
|
func (h *recordingHandler) snapshot() []slog.Record {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
out := make([]slog.Record, len(h.records))
|
||||||
|
copy(out, h.records)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func testLogger() *slog.Logger {
|
func testLogger() *slog.Logger {
|
||||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
}
|
}
|
||||||
@@ -84,7 +112,7 @@ func TestPinningProxy_Forward_Pinned_Success(t *testing.T) {
|
|||||||
upstreamURL := mustParseURL(t, upstream.URL)
|
upstreamURL := mustParseURL(t, upstream.URL)
|
||||||
|
|
||||||
var decideCalls atomic.Int32
|
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) {
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
decideCalls.Add(1)
|
decideCalls.Add(1)
|
||||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||||
@@ -123,7 +151,7 @@ func TestPinningProxy_Forward_Pinned_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPinningProxy_Forward_BlockedByDecide(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) {
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered)
|
return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered)
|
||||||
}
|
}
|
||||||
@@ -159,7 +187,7 @@ func TestPinningProxy_Forward_Bypass(t *testing.T) {
|
|||||||
upstreamURL := mustParseURL(t, upstream.URL)
|
upstreamURL := mustParseURL(t, upstream.URL)
|
||||||
|
|
||||||
var bypassCalls atomic.Int32
|
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) {
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
return gotenberg.OutboundDecision{Bypass: true}, nil
|
return gotenberg.OutboundDecision{Bypass: true}, nil
|
||||||
}
|
}
|
||||||
@@ -208,7 +236,7 @@ func TestPinningProxy_Forward_StripsHopByHopHeaders(t *testing.T) {
|
|||||||
t.Cleanup(upstream.Close)
|
t.Cleanup(upstream.Close)
|
||||||
upstreamURL := mustParseURL(t, upstream.URL)
|
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) {
|
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
|
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||||
}
|
}
|
||||||
@@ -248,7 +276,7 @@ func TestPinningProxy_Forward_StripsHopByHopHeaders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPinningProxy_Forward_RejectsNonAbsoluteURL(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) {
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
t.Fatal("decide must not be called for malformed proxy request")
|
t.Fatal("decide must not be called for malformed proxy request")
|
||||||
return gotenberg.OutboundDecision{}, nil
|
return gotenberg.OutboundDecision{}, nil
|
||||||
@@ -288,7 +316,7 @@ func TestPinningProxy_CONNECT_Pinned_Success(t *testing.T) {
|
|||||||
t.Cleanup(stop)
|
t.Cleanup(stop)
|
||||||
|
|
||||||
var decideCalls atomic.Int32
|
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) {
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
decideCalls.Add(1)
|
decideCalls.Add(1)
|
||||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||||
@@ -358,7 +386,7 @@ func TestPinningProxy_CONNECT_Pinned_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPinningProxy_CONNECT_BlockedByDecide(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) {
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered)
|
return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered)
|
||||||
}
|
}
|
||||||
@@ -417,7 +445,7 @@ func TestPinningProxy_DNSRebind_SingleResolution(t *testing.T) {
|
|||||||
return gotenberg.OutboundDecision{}, fmt.Errorf("rebind lookup: %w", gotenberg.ErrFiltered)
|
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.decide = stubDecide
|
||||||
p.dialPinned = func(_ context.Context, network string, addrs []netip.Addr, _ string) (net.Conn, error) {
|
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" {
|
if len(addrs) != 1 || addrs[0].String() != "93.184.216.34" {
|
||||||
@@ -452,8 +480,395 @@ func TestPinningProxy_DNSRebind_SingleResolution(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug verifies that
|
||||||
|
// when decide fails because the request context was canceled or its
|
||||||
|
// deadline expired (the canonical case is Chromium dropping a speculative
|
||||||
|
// CONNECT before the proxy finishes resolving the host), the proxy logs
|
||||||
|
// at debug and not at warn. Policy refusals must still warn; see
|
||||||
|
// [TestPinningProxy_CONNECT_BlockedByDecide].
|
||||||
|
func TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug(t *testing.T) {
|
||||||
|
rec := &recordingHandler{}
|
||||||
|
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.
|
||||||
|
return gotenberg.OutboundDecision{}, fmt.Errorf("validate '%s' host: resolve %q: lookup %s: %w", "https://www.google.com:443", "www.google.com", "www.google.com", context.Canceled)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := p.Start(slog.New(rec))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("start pinning proxy: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
|
||||||
|
proxyURL := p.URL()
|
||||||
|
|
||||||
|
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial proxy: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
|
||||||
|
_, err = fmt.Fprintf(conn, "CONNECT www.google.com:443 HTTP/1.1\r\nHost: www.google.com:443\r\n\r\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write CONNECT: %v", err)
|
||||||
|
}
|
||||||
|
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read response: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
records := rec.snapshot()
|
||||||
|
var found bool
|
||||||
|
for _, r := range records {
|
||||||
|
if !strings.Contains(r.Message, "www.google.com:443") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
if r.Level != slog.LevelDebug {
|
||||||
|
t.Fatalf("record level = %v, want Debug; message: %s", r.Level, r.Message)
|
||||||
|
}
|
||||||
|
if !strings.Contains(r.Message, "abandoned") {
|
||||||
|
t.Fatalf("message = %q, want it to mention abandoned", r.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("expected a log record mentioning www.google.com:443, found none")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPinningProxy_Forward_ClientCancellation_LoggedAtDebug is the
|
||||||
|
// handleForward equivalent of
|
||||||
|
// [TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug]. Plain
|
||||||
|
// 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, false)
|
||||||
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
|
return gotenberg.OutboundDecision{}, fmt.Errorf("validate host: %w", context.DeadlineExceeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := p.Start(slog.New(rec))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("start pinning proxy: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
|
||||||
|
proxyURL := p.URL()
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: &http.Transport{Proxy: http.ProxyURL(mustParseURL(t, proxyURL))},
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
resp, err := client.Get("http://www.google.com/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET via proxy: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
records := rec.snapshot()
|
||||||
|
var found bool
|
||||||
|
for _, r := range records {
|
||||||
|
if !strings.Contains(r.Message, "www.google.com") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
if r.Level != slog.LevelDebug {
|
||||||
|
t.Fatalf("record level = %v, want Debug; message: %s", r.Level, r.Message)
|
||||||
|
}
|
||||||
|
if !strings.Contains(r.Message, "abandoned") {
|
||||||
|
t.Fatalf("message = %q, want it to mention abandoned", r.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("expected a log record mentioning www.google.com, found none")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPinningProxy_PolicyDenial_LoggedAtWarn protects the existing
|
||||||
|
// behavior: a deny-list match (or any non-cancellation decide error) must
|
||||||
|
// 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, false)
|
||||||
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
|
return gotenberg.OutboundDecision{}, fmt.Errorf("denied: %w", gotenberg.ErrFiltered)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := p.Start(slog.New(rec))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("start pinning proxy: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
|
||||||
|
proxyURL := p.URL()
|
||||||
|
|
||||||
|
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial proxy: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
|
||||||
|
_, err = fmt.Fprintf(conn, "CONNECT denied.example:443 HTTP/1.1\r\nHost: denied.example:443\r\n\r\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write CONNECT: %v", err)
|
||||||
|
}
|
||||||
|
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read response: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
records := rec.snapshot()
|
||||||
|
var found bool
|
||||||
|
for _, r := range records {
|
||||||
|
if !strings.Contains(r.Message, "denied.example") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
if r.Level != slog.LevelWarn {
|
||||||
|
t.Fatalf("record level = %v, want Warn; message: %s", r.Level, r.Message)
|
||||||
|
}
|
||||||
|
if !strings.Contains(r.Message, "blocked") {
|
||||||
|
t.Fatalf("message = %q, want it to mention blocked", r.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("expected a log record mentioning denied.example, found none")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPinningProxy_CONNECT_DialCancellation_LoggedAtDebug verifies that
|
||||||
|
// when the upstream dial fails because the request context was canceled
|
||||||
|
// (typically Chromium dropping a speculative CONNECT before a slow IPv6
|
||||||
|
// dial completes), the proxy logs at debug rather than warn. Genuine
|
||||||
|
// dial failures must still warn; see
|
||||||
|
// [TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn].
|
||||||
|
func TestPinningProxy_CONNECT_DialCancellation_LoggedAtDebug(t *testing.T) {
|
||||||
|
rec := &recordingHandler{}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||||
|
// Mimic a dial canceled mid-flight by the client hanging up,
|
||||||
|
// which is what net.Dialer returns when ctx.Err() is Canceled.
|
||||||
|
return nil, fmt.Errorf("dial tcp [2001:4860:482b:7700::]:443: %w", context.Canceled)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := p.Start(slog.New(rec))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("start pinning proxy: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
|
||||||
|
proxyURL := p.URL()
|
||||||
|
|
||||||
|
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial proxy: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
|
||||||
|
_, err = fmt.Fprintf(conn, "CONNECT www.google.com:443 HTTP/1.1\r\nHost: www.google.com:443\r\n\r\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write CONNECT: %v", err)
|
||||||
|
}
|
||||||
|
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read response: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadGateway {
|
||||||
|
t.Fatalf("status = %d, want 502", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
records := rec.snapshot()
|
||||||
|
var found bool
|
||||||
|
for _, r := range records {
|
||||||
|
if !strings.Contains(r.Message, "CONNECT dial") || !strings.Contains(r.Message, "www.google.com:443") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
if r.Level != slog.LevelDebug {
|
||||||
|
t.Fatalf("record level = %v, want Debug; message: %s", r.Level, r.Message)
|
||||||
|
}
|
||||||
|
if !strings.Contains(r.Message, "abandoned") {
|
||||||
|
t.Fatalf("message = %q, want it to mention abandoned", r.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("expected a log record mentioning CONNECT dial for www.google.com:443, found none")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn guards the existing
|
||||||
|
// behavior: a genuine dial failure (host unreachable, refused, etc.)
|
||||||
|
// must still warn so operators see real problems.
|
||||||
|
func TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn(t *testing.T) {
|
||||||
|
rec := &recordingHandler{}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||||
|
return nil, errors.New("connection refused")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := p.Start(slog.New(rec))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("start pinning proxy: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
|
||||||
|
proxyURL := p.URL()
|
||||||
|
|
||||||
|
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial proxy: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
|
||||||
|
_, err = fmt.Fprintf(conn, "CONNECT real.example:443 HTTP/1.1\r\nHost: real.example:443\r\n\r\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write CONNECT: %v", err)
|
||||||
|
}
|
||||||
|
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read response: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
records := rec.snapshot()
|
||||||
|
var found bool
|
||||||
|
for _, r := range records {
|
||||||
|
if !strings.Contains(r.Message, "CONNECT dial") || !strings.Contains(r.Message, "real.example") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
if r.Level != slog.LevelWarn {
|
||||||
|
t.Fatalf("record level = %v, want Warn; message: %s", r.Level, r.Message)
|
||||||
|
}
|
||||||
|
if !strings.Contains(r.Message, "failed") {
|
||||||
|
t.Fatalf("message = %q, want it to mention failed", r.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("expected a log record mentioning CONNECT dial for real.example, found none")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPinningProxy_Forward_RoundTripCancellation_LoggedAtDebug verifies
|
||||||
|
// the handleForward dial-cancellation path: when the inner Transport's
|
||||||
|
// dial returns a canceled error (client hung up mid-dial), the proxy
|
||||||
|
// 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, 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
|
||||||
|
}
|
||||||
|
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||||
|
return nil, fmt.Errorf("dial tcp [2001:4860::]:80: %w", context.Canceled)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := p.Start(slog.New(rec))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("start pinning proxy: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
|
||||||
|
proxyURL := p.URL()
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: &http.Transport{Proxy: http.ProxyURL(mustParseURL(t, proxyURL))},
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
resp, err := client.Get("http://www.google.com/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET via proxy: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadGateway {
|
||||||
|
t.Fatalf("status = %d, want 502", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
records := rec.snapshot()
|
||||||
|
var found bool
|
||||||
|
for _, r := range records {
|
||||||
|
if !strings.Contains(r.Message, "forward RoundTrip") || !strings.Contains(r.Message, "www.google.com") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
if r.Level != slog.LevelDebug {
|
||||||
|
t.Fatalf("record level = %v, want Debug; message: %s", r.Level, r.Message)
|
||||||
|
}
|
||||||
|
if !strings.Contains(r.Message, "abandoned") {
|
||||||
|
t.Fatalf("message = %q, want it to mention abandoned", r.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("expected a log record mentioning forward RoundTrip for www.google.com, found none")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsClientCancellation(t *testing.T) {
|
||||||
|
canceledCtx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
ctx context.Context
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "wrapped context.Canceled",
|
||||||
|
ctx: context.Background(),
|
||||||
|
err: fmt.Errorf("validate host: %w", context.Canceled),
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrapped context.DeadlineExceeded",
|
||||||
|
ctx: context.Background(),
|
||||||
|
err: fmt.Errorf("validate host: %w", context.DeadlineExceeded),
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "request context already done",
|
||||||
|
ctx: canceledCtx,
|
||||||
|
err: errors.New("some unrelated error"),
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "policy denial",
|
||||||
|
ctx: context.Background(),
|
||||||
|
err: fmt.Errorf("denied: %w", gotenberg.ErrFiltered),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain dial failure",
|
||||||
|
ctx: context.Background(),
|
||||||
|
err: errors.New("connection refused"),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := isClientCancellation(tc.ctx, tc.err)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("isClientCancellation = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPinningProxy_StartTwice(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())
|
err := p.Start(testLogger())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("first Start: %v", err)
|
t.Fatalf("first Start: %v", err)
|
||||||
@@ -467,7 +882,7 @@ func TestPinningProxy_StartTwice(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPinningProxy_StopIdempotent(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.
|
// Stop on a never-started proxy is a no-op.
|
||||||
if err := p.Stop(testLogger()); err != nil {
|
if err := p.Stop(testLogger()); err != nil {
|
||||||
t.Fatalf("Stop on never-started proxy: %v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -341,11 +341,12 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
|||||||
defaultScreenshotOptions := DefaultScreenshotOptions()
|
defaultScreenshotOptions := DefaultScreenshotOptions()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
width, height int
|
width, height int
|
||||||
clip bool
|
clip bool
|
||||||
format string
|
format string
|
||||||
quality int
|
quality int
|
||||||
optimizeForSpeed bool
|
optimizeForSpeed bool
|
||||||
|
deviceScaleFactor float64
|
||||||
)
|
)
|
||||||
|
|
||||||
form.
|
form.
|
||||||
@@ -388,16 +389,18 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
|||||||
quality = intValue
|
quality = intValue
|
||||||
return nil
|
return nil
|
||||||
}).
|
}).
|
||||||
Bool("optimizeForSpeed", &optimizeForSpeed, defaultScreenshotOptions.OptimizeForSpeed)
|
Bool("optimizeForSpeed", &optimizeForSpeed, defaultScreenshotOptions.OptimizeForSpeed).
|
||||||
|
Float64("deviceScaleFactor", &deviceScaleFactor, defaultScreenshotOptions.DeviceScaleFactor)
|
||||||
|
|
||||||
screenshotOptions := ScreenshotOptions{
|
screenshotOptions := ScreenshotOptions{
|
||||||
Options: options,
|
Options: options,
|
||||||
Width: width,
|
Width: width,
|
||||||
Height: height,
|
Height: height,
|
||||||
Clip: clip,
|
Clip: clip,
|
||||||
Format: format,
|
Format: format,
|
||||||
Quality: quality,
|
Quality: quality,
|
||||||
OptimizeForSpeed: optimizeForSpeed,
|
OptimizeForSpeed: optimizeForSpeed,
|
||||||
|
DeviceScaleFactor: deviceScaleFactor,
|
||||||
}
|
}
|
||||||
|
|
||||||
return form, screenshotOptions
|
return form, screenshotOptions
|
||||||
@@ -443,7 +446,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
mode := pdfengines.FormDataPdfSplitMode(form, false)
|
mode := pdfengines.FormDataPdfSplitMode(form, false)
|
||||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||||
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
||||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||||
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
||||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
||||||
@@ -451,6 +454,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||||
|
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||||
|
|
||||||
var url string
|
var url string
|
||||||
err := form.
|
err := form.
|
||||||
@@ -474,7 +478,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("validate stamp: %w", err)
|
return fmt.Errorf("validate stamp: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
|
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert URL to PDF: %w", err)
|
return fmt.Errorf("convert URL to PDF: %w", err)
|
||||||
}
|
}
|
||||||
@@ -531,7 +535,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
mode := pdfengines.FormDataPdfSplitMode(form, false)
|
mode := pdfengines.FormDataPdfSplitMode(form, false)
|
||||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||||
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
||||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||||
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
||||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
||||||
@@ -539,6 +543,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||||
|
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||||
|
|
||||||
var inputPath string
|
var inputPath string
|
||||||
err := form.
|
err := form.
|
||||||
@@ -559,7 +564,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
|
|
||||||
url := fmt.Sprintf("file://%s", inputPath)
|
url := fmt.Sprintf("file://%s", inputPath)
|
||||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
||||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
|
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert HTML to PDF: %w", err)
|
return fmt.Errorf("convert HTML to PDF: %w", err)
|
||||||
}
|
}
|
||||||
@@ -613,7 +618,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
mode := pdfengines.FormDataPdfSplitMode(form, false)
|
mode := pdfengines.FormDataPdfSplitMode(form, false)
|
||||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||||
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
||||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||||
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
||||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
||||||
@@ -621,6 +626,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||||
|
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
inputPath string
|
inputPath string
|
||||||
@@ -650,7 +656,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
}
|
}
|
||||||
|
|
||||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
||||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
|
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert markdown to PDF: %w", err)
|
return fmt.Errorf("convert markdown to PDF: %w", err)
|
||||||
}
|
}
|
||||||
@@ -775,7 +781,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
|
|||||||
return fmt.Sprintf("file://%s", inputPath), nil
|
return fmt.Sprintf("file://%s", inputPath), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, embedPaths []string, embedsMetadata map[string]map[string]string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error {
|
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, encrypt gotenberg.EncryptOptions, embedPaths []string, embedsMetadata map[string]map[string]string, facturX gotenberg.FacturX, facturxXmlPath string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error {
|
||||||
outputPath := ctx.GeneratePath(".pdf")
|
outputPath := ctx.GeneratePath(".pdf")
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/1130.
|
// See https://github.com/gotenberg/gotenberg/issues/1130.
|
||||||
filename := ctx.OutputFilename(outputPath)
|
filename := ctx.OutputFilename(outputPath)
|
||||||
@@ -837,7 +843,17 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
|||||||
return fmt.Errorf("convert to PDF: %w", err)
|
return fmt.Errorf("convert to PDF: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
|
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pdfengines.ValidatePdfEncryptCompat(encrypt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pdfengines.ValidateFacturXCompat(facturX, facturxXmlPath, pdfFormats)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -862,6 +878,8 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
|||||||
return fmt.Errorf("rotate PDFs: %w", err)
|
return fmt.Errorf("rotate PDFs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pdfFormats = pdfengines.FacturXPdfFormats(ctx, engine, facturX, pdfFormats, true, nil)
|
||||||
|
|
||||||
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert PDF(s): %w", err)
|
return fmt.Errorf("convert PDF(s): %w", err)
|
||||||
@@ -884,7 +902,12 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
|||||||
return fmt.Errorf("set embeds metadata: %w", err)
|
return fmt.Errorf("set embeds metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
|
err = pdfengines.ApplyFacturXStub(ctx, engine, facturX, facturxXmlPath, convertOutputPaths)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("apply Factur-X: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pdfengines.EncryptPdfStub(ctx, engine, encrypt, convertOutputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,104 +14,169 @@ import (
|
|||||||
"github.com/chromedp/cdproto/network"
|
"github.com/chromedp/cdproto/network"
|
||||||
"github.com/chromedp/cdproto/page"
|
"github.com/chromedp/cdproto/page"
|
||||||
"github.com/chromedp/chromedp"
|
"github.com/chromedp/chromedp"
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
"go.opentelemetry.io/otel/codes"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func printToPdfActionFunc(logger *slog.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc {
|
// 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 {
|
return func(ctx context.Context) error {
|
||||||
paperHeight := options.PaperHeight
|
if options.GenerateDocumentOutline && !options.GenerateTaggedPdf {
|
||||||
pageRanges := options.PageRanges
|
logger.DebugContext(ctx, "document outline requested, enabling tagged PDF because Chromium derives the outline from the structure tree")
|
||||||
|
|
||||||
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().
|
options = resolvePdfOptions(options)
|
||||||
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 ||
|
// ctx is the chromedp task context, derived from context.Background(),
|
||||||
options.FooterTemplate != DefaultPdfOptions().FooterTemplate
|
// 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 {
|
err := func() error {
|
||||||
logger.DebugContext(ctx, "no custom header nor footer")
|
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 {
|
} else {
|
||||||
logger.DebugContext(ctx, "with custom header and/or footer")
|
span.SetStatus(codes.Ok, "")
|
||||||
|
|
||||||
printToPdf = printToPdf.
|
|
||||||
WithDisplayHeaderFooter(true).
|
|
||||||
WithHeaderTemplate(options.HeaderTemplate).
|
|
||||||
WithFooterTemplate(options.FooterTemplate)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugContext(ctx, fmt.Sprintf("print to PDF with: %+v", printToPdf))
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_, stream, err := printToPdf.Do(ctx)
|
// printToPdfAttrs derives bounded, low-cardinality attributes from the print
|
||||||
if err != nil {
|
// options. Raw header/footer templates and page ranges are reduced to booleans
|
||||||
return fmt.Errorf("print to PDF: %w", err)
|
// to avoid leaking document content and exploding cardinality.
|
||||||
}
|
func printToPdfAttrs(options PdfOptions) []attribute.KeyValue {
|
||||||
|
return []attribute.KeyValue{
|
||||||
reader := &streamReader{
|
attribute.Bool("gotenberg.chromium.print.landscape", options.Landscape),
|
||||||
ctx: ctx,
|
attribute.Bool("gotenberg.chromium.print.print_background", options.PrintBackground),
|
||||||
handle: stream,
|
attribute.Float64("gotenberg.chromium.print.scale", options.Scale),
|
||||||
r: nil,
|
attribute.Float64("gotenberg.chromium.print.paper_width", options.PaperWidth),
|
||||||
pos: 0,
|
attribute.Float64("gotenberg.chromium.print.paper_height", options.PaperHeight),
|
||||||
eof: false,
|
attribute.Bool("gotenberg.chromium.print.single_page", options.SinglePage),
|
||||||
}
|
attribute.Bool("gotenberg.chromium.print.prefer_css_page_size", options.PreferCssPageSize),
|
||||||
|
attribute.Bool("gotenberg.chromium.print.generate_tagged_pdf", options.GenerateTaggedPdf),
|
||||||
defer func() {
|
attribute.Bool("gotenberg.chromium.print.has_page_ranges", options.PageRanges != ""),
|
||||||
err = reader.Close()
|
attribute.Bool("gotenberg.chromium.print.has_header", options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate),
|
||||||
if err != nil {
|
attribute.Bool("gotenberg.chromium.print.has_footer", options.FooterTemplate != DefaultPdfOptions().FooterTemplate),
|
||||||
logger.ErrorContext(ctx, fmt.Sprintf("close reader: %s", err))
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY, 0o600)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open output path: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
err = file.Close()
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorContext(ctx, fmt.Sprintf("close output path: %s", err))
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
buffer := bufio.NewReader(reader)
|
|
||||||
|
|
||||||
_, err = buffer.WriteTo(file)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("write result to output path: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,11 +229,11 @@ func captureScreenshotActionFunc(logger *slog.Logger, outputPath string, options
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setDeviceMetricsOverride(logger *slog.Logger, width, height int) chromedp.ActionFunc {
|
func setDeviceMetricsOverride(logger *slog.Logger, width, height int, deviceScaleFactor float64) chromedp.ActionFunc {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger.DebugContext(ctx, "set device metrics override")
|
logger.DebugContext(ctx, "set device metrics override")
|
||||||
|
|
||||||
err := emulation.SetDeviceMetricsOverride(int64(width), int64(height), 1.0, false).Do(ctx)
|
err := emulation.SetDeviceMetricsOverride(int64(width), int64(height), deviceScaleFactor, false).Do(ctx)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -332,11 +397,13 @@ func navigateActionFunc(logger *slog.Logger, url string, skipNetworkIdleEvent, s
|
|||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger.DebugContext(ctx, fmt.Sprintf("navigate to '%s'", url))
|
logger.DebugContext(ctx, fmt.Sprintf("navigate to '%s'", url))
|
||||||
|
|
||||||
_, _, _, _, err := page.Navigate(url).Do(ctx)
|
// Register lifecycle listeners before issuing Page.navigate. For
|
||||||
if err != nil {
|
// fast loads (typically file:// pages with no external
|
||||||
return fmt.Errorf("navigate to '%s': %w", url, err)
|
// sub-resources), DomContentEventFired / LoadEventFired /
|
||||||
}
|
// LoadingFinished can fire between Navigate.Do returning and
|
||||||
|
// runBatch spawning the waiter goroutines. Registering ahead of
|
||||||
|
// the navigate command closes that race.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1561.
|
||||||
waitFunc := []func() error{
|
waitFunc := []func() error{
|
||||||
waitForEventDomContentEventFired(ctx, logger),
|
waitForEventDomContentEventFired(ctx, logger),
|
||||||
waitForEventLoadEventFired(ctx, logger),
|
waitForEventLoadEventFired(ctx, logger),
|
||||||
@@ -355,6 +422,11 @@ func navigateActionFunc(logger *slog.Logger, url string, skipNetworkIdleEvent, s
|
|||||||
logger.DebugContext(ctx, "skipping network almost idle event")
|
logger.DebugContext(ctx, "skipping network almost idle event")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, _, _, _, err := page.Navigate(url).Do(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("navigate to '%s': %w", url, err)
|
||||||
|
}
|
||||||
|
|
||||||
err = runBatch(
|
err = runBatch(
|
||||||
ctx,
|
ctx,
|
||||||
waitFunc...,
|
waitFunc...,
|
||||||
|
|||||||
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"
|
"os/exec"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.opentelemetry.io/otel/codes"
|
"go.opentelemetry.io/otel/codes"
|
||||||
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
||||||
"go.opentelemetry.io/otel/trace"
|
"go.opentelemetry.io/otel/trace"
|
||||||
@@ -159,6 +161,9 @@ func buildExifToolWriteArgs(metadata map[string]any) ([]string, error) {
|
|||||||
// [gotenberg.PdfEngine] interface.
|
// [gotenberg.PdfEngine] interface.
|
||||||
type ExifTool struct {
|
type ExifTool struct {
|
||||||
binPath string
|
binPath string
|
||||||
|
|
||||||
|
version string
|
||||||
|
versionOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
// Descriptor returns [ExifTool]'s module descriptor.
|
// Descriptor returns [ExifTool]'s module descriptor.
|
||||||
@@ -193,26 +198,53 @@ func (engine *ExifTool) Validate() error {
|
|||||||
|
|
||||||
// Debug returns additional debug data.
|
// Debug returns additional debug data.
|
||||||
func (engine *ExifTool) Debug() map[string]any {
|
func (engine *ExifTool) Debug() map[string]any {
|
||||||
debug := make(map[string]any)
|
return map[string]any{"version": engine.detectVersion()}
|
||||||
|
}
|
||||||
|
|
||||||
cmd := exec.Command(engine.binPath, "-ver") //nolint:gosec
|
// detectVersion resolves the ExifTool version once, preferring the value
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
// captured at image build time so it never spawns ExifTool at runtime. It falls
|
||||||
|
// back to running exiftool -ver for local or non-Docker builds.
|
||||||
|
func (engine *ExifTool) detectVersion() string {
|
||||||
|
engine.versionOnce.Do(func() {
|
||||||
|
if v, ok := gotenberg.BuildVersion("exiftool"); ok {
|
||||||
|
engine.version = v
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
output, err := cmd.Output()
|
cmd := exec.Command(engine.binPath, "-ver") //nolint:gosec
|
||||||
if err != nil {
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
debug["version"] = err.Error()
|
|
||||||
return debug
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
engine.version = err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.version = strings.TrimSpace(string(output))
|
||||||
|
})
|
||||||
|
|
||||||
|
return engine.version
|
||||||
|
}
|
||||||
|
|
||||||
|
// spanAttrs returns the client-span attributes for an ExifTool invocation: the
|
||||||
|
// server address and the ExifTool version, plus any extra attributes. The
|
||||||
|
// version rides on every span so a trace records which ExifTool ran the
|
||||||
|
// operation.
|
||||||
|
func (engine *ExifTool) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue {
|
||||||
|
attrs := make([]attribute.KeyValue, 0, 2+len(extra))
|
||||||
|
attrs = append(attrs, semconv.ServerAddress(engine.binPath))
|
||||||
|
if v := engine.detectVersion(); v != "" {
|
||||||
|
attrs = append(attrs, attribute.String("gotenberg.exiftool.version", v))
|
||||||
}
|
}
|
||||||
|
|
||||||
debug["version"] = strings.TrimSpace(string(output))
|
return append(attrs, extra...)
|
||||||
return debug
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge is not available in this implementation.
|
// Merge is not available in this implementation.
|
||||||
func (engine *ExifTool) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
func (engine *ExifTool) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Merge",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Merge",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -226,7 +258,7 @@ func (engine *ExifTool) Merge(ctx context.Context, logger *slog.Logger, inputPat
|
|||||||
func (engine *ExifTool) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
func (engine *ExifTool) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Split",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Split",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -240,7 +272,7 @@ func (engine *ExifTool) Split(ctx context.Context, logger *slog.Logger, mode got
|
|||||||
func (engine *ExifTool) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
|
func (engine *ExifTool) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Flatten",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Flatten",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -254,7 +286,7 @@ func (engine *ExifTool) Flatten(ctx context.Context, logger *slog.Logger, inputP
|
|||||||
func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Convert",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Convert",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -269,7 +301,7 @@ func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, format
|
|||||||
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.ReadMetadata",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.ReadMetadata",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -326,7 +358,7 @@ func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *slog.Logger, i
|
|||||||
func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
|
func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.WriteMetadata",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.WriteMetadata",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -371,7 +403,7 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *slog.Logger,
|
|||||||
func (engine *ExifTool) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
|
func (engine *ExifTool) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.PageCount",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.PageCount",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -424,7 +456,7 @@ func (engine *ExifTool) PageCount(ctx context.Context, logger *slog.Logger, inpu
|
|||||||
func (engine *ExifTool) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
|
func (engine *ExifTool) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.WriteBookmarks",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.WriteBookmarks",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -438,7 +470,7 @@ func (engine *ExifTool) WriteBookmarks(ctx context.Context, logger *slog.Logger,
|
|||||||
func (engine *ExifTool) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
|
func (engine *ExifTool) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.ReadBookmarks",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.ReadBookmarks",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -449,10 +481,10 @@ func (engine *ExifTool) ReadBookmarks(ctx context.Context, logger *slog.Logger,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt is not available in this implementation.
|
// Encrypt is not available in this implementation.
|
||||||
func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
|
func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Encrypt",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Encrypt",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -466,7 +498,7 @@ func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputP
|
|||||||
func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.EmbedFiles",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.EmbedFiles",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -480,7 +512,7 @@ func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *slog.Logger, fil
|
|||||||
func (engine *ExifTool) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
func (engine *ExifTool) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Watermark",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Watermark",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -494,7 +526,7 @@ func (engine *ExifTool) Watermark(ctx context.Context, logger *slog.Logger, inpu
|
|||||||
func (engine *ExifTool) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
func (engine *ExifTool) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Stamp",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Stamp",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -508,7 +540,7 @@ func (engine *ExifTool) Stamp(ctx context.Context, logger *slog.Logger, inputPat
|
|||||||
func (engine *ExifTool) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
|
func (engine *ExifTool) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Rotate",
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Rotate",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -523,6 +555,16 @@ func (engine *ExifTool) EmbedFilesMetadata(ctx context.Context, logger *slog.Log
|
|||||||
return fmt.Errorf("set embeds metadata with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
return fmt.Errorf("set embeds metadata with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InjectFacturXXMP is not available in this implementation.
|
||||||
|
func (engine *ExifTool) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error {
|
||||||
|
return fmt.Errorf("inject Factur-X XMP with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadPdfAConformance is not available in this implementation.
|
||||||
|
func (engine *ExifTool) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
|
||||||
|
return "", "", fmt.Errorf("read PDF/A conformance with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
// Interface guards.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ gotenberg.Module = (*ExifTool)(nil)
|
_ gotenberg.Module = (*ExifTool)(nil)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ var (
|
|||||||
ErrUnoException = errors.New("uno exception")
|
ErrUnoException = errors.New("uno exception")
|
||||||
|
|
||||||
// ErrRuntimeException happens when unoconverter returns exit code 6.
|
// ErrRuntimeException happens when unoconverter returns exit code 6.
|
||||||
ErrRuntimeException = errors.New("uno exception")
|
ErrRuntimeException = errors.New("runtime exception")
|
||||||
|
|
||||||
// ErrCoreDumped happens randomly; sometimes a conversion will work as
|
// ErrCoreDumped happens randomly; sometimes a conversion will work as
|
||||||
// expected, and some other time the same conversion will fail.
|
// expected, and some other time the same conversion will fail.
|
||||||
@@ -53,11 +54,15 @@ type Api struct {
|
|||||||
libreOffice libreOffice
|
libreOffice libreOffice
|
||||||
supervisor gotenberg.ProcessSupervisor
|
supervisor gotenberg.ProcessSupervisor
|
||||||
|
|
||||||
|
version string
|
||||||
|
versionOnce sync.Once
|
||||||
|
|
||||||
reqsCounter metric.Int64Counter
|
reqsCounter metric.Int64Counter
|
||||||
errsCounter metric.Int64Counter
|
errsCounter metric.Int64Counter
|
||||||
conversionDurationCounter metric.Float64Histogram
|
conversionDurationCounter metric.Float64Histogram
|
||||||
queueWaitDurationCounter metric.Float64Histogram
|
queueWaitDurationCounter metric.Float64Histogram
|
||||||
pdfOutputSizeCounter metric.Int64Histogram
|
pdfOutputSizeCounter metric.Int64Histogram
|
||||||
|
coreDumpedRetriesCounter metric.Int64Counter
|
||||||
}
|
}
|
||||||
|
|
||||||
// Options gathers available options when converting a document to PDF.
|
// Options gathers available options when converting a document to PDF.
|
||||||
@@ -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.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-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-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
|
return fs
|
||||||
}(),
|
}(),
|
||||||
@@ -358,10 +364,11 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
|||||||
unoBinPath: unoBinPath,
|
unoBinPath: unoBinPath,
|
||||||
startTimeout: flags.MustDuration("libreoffice-start-timeout"),
|
startTimeout: flags.MustDuration("libreoffice-start-timeout"),
|
||||||
proxyOptions: outboundProxyOptions{
|
proxyOptions: outboundProxyOptions{
|
||||||
allowList: flags.MustRegexpSlice("libreoffice-allow-list"),
|
allowList: flags.MustRegexpSlice("libreoffice-allow-list"),
|
||||||
denyList: flags.MustRegexpSlice("libreoffice-deny-list"),
|
denyList: flags.MustRegexpSlice("libreoffice-deny-list"),
|
||||||
denyPrivateIPs: flags.MustBool("libreoffice-deny-private-ips"),
|
denyPrivateIPs: flags.MustBool("libreoffice-deny-private-ips"),
|
||||||
denyPublicIPs: flags.MustBool("libreoffice-deny-public-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.
|
// Process.
|
||||||
a.libreOffice = newLibreOfficeProcess(a.args)
|
a.libreOffice = newLibreOfficeProcess(a.args)
|
||||||
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"), 1, flags.MustDuration("libreoffice-idle-shutdown-timeout"))
|
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, "libreoffice", a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"), 1, flags.MustDuration("libreoffice-idle-shutdown-timeout"))
|
||||||
|
|
||||||
// Metrics.
|
// Metrics.
|
||||||
meter := gotenberg.Meter()
|
meter := gotenberg.Meter()
|
||||||
@@ -468,6 +475,15 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
|||||||
return fmt.Errorf("create libreoffice.pdf.output.size histogram: %w", err)
|
return fmt.Errorf("create libreoffice.pdf.output.size histogram: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.coreDumpedRetriesCounter, err = meter.Int64Counter(
|
||||||
|
"libreoffice.conversion.retries.total",
|
||||||
|
metric.WithDescription("Total number of LibreOffice conversion retries after a core dump"),
|
||||||
|
metric.WithUnit("{retry}"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create libreoffice.conversion.retries.total counter: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,12 +493,12 @@ func (a *Api) Validate() error {
|
|||||||
|
|
||||||
_, statErr := os.Stat(a.args.binPath)
|
_, statErr := os.Stat(a.args.binPath)
|
||||||
if os.IsNotExist(statErr) {
|
if os.IsNotExist(statErr) {
|
||||||
err = errors.Join(err, fmt.Errorf("LibreOffice binary path does not exist: %w", statErr))
|
err = errors.Join(err, fmt.Errorf("LibreOffice binary does not exist at %q; check the LIBREOFFICE_BIN_PATH environment variable: %w", a.args.binPath, statErr))
|
||||||
}
|
}
|
||||||
|
|
||||||
_, statErr = os.Stat(a.args.unoBinPath)
|
_, statErr = os.Stat(a.args.unoBinPath)
|
||||||
if os.IsNotExist(statErr) {
|
if os.IsNotExist(statErr) {
|
||||||
err = errors.Join(err, fmt.Errorf("unoconverter binary path does not exist: %w", statErr))
|
err = errors.Join(err, fmt.Errorf("unoconverter binary does not exist at %q; check the UNOCONVERTER_BIN_PATH environment variable: %w", a.args.unoBinPath, statErr))
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
@@ -530,19 +546,46 @@ func (a *Api) Stop(ctx context.Context) error {
|
|||||||
|
|
||||||
// Debug returns additional debug data.
|
// Debug returns additional debug data.
|
||||||
func (a *Api) Debug() map[string]any {
|
func (a *Api) Debug() map[string]any {
|
||||||
debug := make(map[string]any)
|
return map[string]any{"version": a.detectVersion()}
|
||||||
|
}
|
||||||
|
|
||||||
cmd := exec.Command(a.args.binPath, "--version") //nolint:gosec
|
// detectVersion resolves the LibreOffice version once, preferring the value
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
// captured at image build time so it never spawns LibreOffice at runtime. It
|
||||||
|
// falls back to running soffice --version for local or non-Docker builds.
|
||||||
|
func (a *Api) detectVersion() string {
|
||||||
|
a.versionOnce.Do(func() {
|
||||||
|
if v, ok := gotenberg.BuildVersion("libreoffice-api"); ok {
|
||||||
|
a.version = v
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
output, err := cmd.Output()
|
cmd := exec.Command(a.args.binPath, "--version") //nolint:gosec
|
||||||
if err != nil {
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
debug["version"] = err.Error()
|
|
||||||
return debug
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
a.version = err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.version = strings.TrimSpace(string(output))
|
||||||
|
})
|
||||||
|
|
||||||
|
return a.version
|
||||||
|
}
|
||||||
|
|
||||||
|
// spanAttrs returns the client-span attributes for a LibreOffice invocation:
|
||||||
|
// the server address and the LibreOffice version, plus any extra attributes.
|
||||||
|
// The version rides on every conversion span so a trace records which
|
||||||
|
// LibreOffice rendered the document.
|
||||||
|
func (a *Api) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue {
|
||||||
|
attrs := make([]attribute.KeyValue, 0, 2+len(extra))
|
||||||
|
attrs = append(attrs, semconv.ServerAddress(a.args.binPath))
|
||||||
|
if v := a.detectVersion(); v != "" {
|
||||||
|
attrs = append(attrs, attribute.String("gotenberg.libreoffice.version", v))
|
||||||
}
|
}
|
||||||
|
|
||||||
debug["version"] = strings.TrimSpace(string(output))
|
return append(attrs, extra...)
|
||||||
return debug
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metrics returns the metrics.
|
// Metrics returns the metrics.
|
||||||
@@ -618,76 +661,123 @@ func (a *Api) LibreOffice() (Uno, error) {
|
|||||||
func (a *Api) Pdf(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error {
|
func (a *Api) Pdf(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "libreoffice.Pdf",
|
ctx, span := gotenberg.Tracer().Start(ctx, "libreoffice.Pdf",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(a.args.binPath)),
|
trace.WithAttributes(a.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
start := time.Now()
|
span.SetAttributes(
|
||||||
var conversionStart time.Time
|
attribute.Int64("gotenberg.queue.depth_at_arrival", a.supervisor.ReqQueueSize()),
|
||||||
|
attribute.Int64("gotenberg.conversions_since_last_restart", a.supervisor.ConversionsSinceRestart()),
|
||||||
|
)
|
||||||
|
span.SetAttributes(conversionRequestAttributes(inputPath, options)...)
|
||||||
|
|
||||||
err := a.supervisor.Run(ctx, logger, func() error {
|
// ErrCoreDumped happens randomly (https://github.com/gotenberg/gotenberg/issues/639);
|
||||||
conversionStart = time.Now()
|
// retry the conversion, but cap the retries so a permanently failing
|
||||||
return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options)
|
// document cannot loop forever. Each attempt records its own metrics.
|
||||||
})
|
const maxCoreDumpedRetries = 10
|
||||||
|
|
||||||
// Determine status and error reason.
|
var err error
|
||||||
status := "success"
|
var reason string
|
||||||
reason := ""
|
for attempt := 0; ; attempt++ {
|
||||||
|
start := time.Now()
|
||||||
|
var conversionStart time.Time
|
||||||
|
|
||||||
if err != nil {
|
err = a.supervisor.Run(ctx, logger, func() error {
|
||||||
switch {
|
conversionStart = time.Now()
|
||||||
case errors.Is(err, context.DeadlineExceeded):
|
return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options)
|
||||||
status = "timeout"
|
})
|
||||||
reason = "timeout"
|
|
||||||
case errors.Is(err, context.Canceled):
|
// Determine status and error reason.
|
||||||
|
status := "success"
|
||||||
|
reason = ""
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
status = "error"
|
status = "error"
|
||||||
reason = "context_cancelled"
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) || errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
|
status = "timeout"
|
||||||
status = "error"
|
}
|
||||||
reason = "libreoffice_unavailable"
|
reason = libreofficeErrorType(err)
|
||||||
default:
|
|
||||||
status = "error"
|
|
||||||
reason = "unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record metrics.
|
|
||||||
attrs := metric.WithAttributes(attribute.String("status", status))
|
|
||||||
a.reqsCounter.Add(ctx, 1, attrs)
|
|
||||||
|
|
||||||
if reason != "" {
|
|
||||||
a.errsCounter.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
|
|
||||||
}
|
|
||||||
|
|
||||||
if !conversionStart.IsZero() {
|
|
||||||
queueWait := conversionStart.Sub(start).Seconds()
|
|
||||||
a.queueWaitDurationCounter.Record(ctx, queueWait, attrs)
|
|
||||||
|
|
||||||
conversionDuration := time.Since(conversionStart).Seconds()
|
|
||||||
a.conversionDurationCounter.Record(ctx, conversionDuration, attrs)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
stat, statErr := os.Stat(outputPath)
|
|
||||||
if statErr == nil {
|
|
||||||
a.pdfOutputSizeCounter.Record(ctx, stat.Size(), attrs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
span.SetStatus(codes.Ok, "")
|
// Record metrics for this attempt.
|
||||||
return nil
|
attrs := metric.WithAttributes(attribute.String("status", status))
|
||||||
}
|
a.reqsCounter.Add(ctx, 1, attrs)
|
||||||
|
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/639.
|
if reason != "" {
|
||||||
if errors.Is(err, ErrCoreDumped) {
|
a.errsCounter.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
|
||||||
logger.DebugContext(ctx, fmt.Sprintf("got a '%s' error, retry conversion", err))
|
}
|
||||||
return a.Pdf(ctx, logger, inputPath, outputPath, options)
|
|
||||||
|
if !conversionStart.IsZero() {
|
||||||
|
queueWait := conversionStart.Sub(start).Seconds()
|
||||||
|
a.queueWaitDurationCounter.Record(ctx, queueWait, attrs)
|
||||||
|
|
||||||
|
conversionDuration := time.Since(conversionStart).Seconds()
|
||||||
|
a.conversionDurationCounter.Record(ctx, conversionDuration, attrs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
stat, statErr := os.Stat(outputPath)
|
||||||
|
if statErr == nil {
|
||||||
|
a.pdfOutputSizeCounter.Record(ctx, stat.Size(), attrs)
|
||||||
|
span.SetAttributes(attribute.Int64("gotenberg.conversion.output.bytes", stat.Size()))
|
||||||
|
}
|
||||||
|
|
||||||
|
span.SetStatus(codes.Ok, "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, ErrCoreDumped) && attempt < maxCoreDumpedRetries {
|
||||||
|
logger.DebugContext(ctx, fmt.Sprintf("got a '%s' error, retry conversion (attempt %d)", err, attempt+1))
|
||||||
|
span.AddEvent("conversion.retry", trace.WithAttributes(
|
||||||
|
attribute.Int("attempt", attempt+1),
|
||||||
|
))
|
||||||
|
a.coreDumpedRetriesCounter.Add(ctx, 1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
gotenberg.SpanErrorType(span, reason)
|
||||||
span.RecordError(err)
|
span.RecordError(err)
|
||||||
span.SetStatus(codes.Error, err.Error())
|
span.SetStatus(codes.Error, err.Error())
|
||||||
return fmt.Errorf("supervisor run task: %w", err)
|
return fmt.Errorf("supervisor run task: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// conversionRequestAttributes derives low-cardinality attributes describing the
|
||||||
|
// requested conversion: the input document size and the requested PDF format
|
||||||
|
// options.
|
||||||
|
func conversionRequestAttributes(inputPath string, options Options) []attribute.KeyValue {
|
||||||
|
attrs := []attribute.KeyValue{
|
||||||
|
attribute.String("gotenberg.libreoffice.pdf_a", options.PdfFormats.PdfA),
|
||||||
|
attribute.Bool("gotenberg.libreoffice.pdf_ua", options.PdfFormats.PdfUa),
|
||||||
|
attribute.Bool("gotenberg.conversion.landscape", options.Landscape),
|
||||||
|
attribute.Bool("gotenberg.conversion.has_page_ranges", options.PageRanges != ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
if info, err := os.Stat(inputPath); err == nil {
|
||||||
|
attrs = append(attrs, attribute.Int64("gotenberg.conversion.input.bytes", info.Size()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
}
|
||||||
|
|
||||||
|
// libreofficeErrorType maps a conversion error to LibreOffice's bounded reason
|
||||||
|
// value, reused as the span error.type. Generic failures fall back to
|
||||||
|
// [gotenberg.ClassifyError].
|
||||||
|
func libreofficeErrorType(err error) string {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrInvalidPdfFormats):
|
||||||
|
return gotenberg.ErrorTypeInvalidInput
|
||||||
|
case errors.Is(err, ErrUnoException), errors.Is(err, ErrRuntimeException):
|
||||||
|
return "libreoffice_exception"
|
||||||
|
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded), errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
|
||||||
|
return "libreoffice_unavailable"
|
||||||
|
default:
|
||||||
|
return gotenberg.ClassifyError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Extensions returns the file extensions available for conversions.
|
// Extensions returns the file extensions available for conversions.
|
||||||
// FIXME: don't care, take all on the route level?
|
// FIXME: don't care, take all on the route level?
|
||||||
func (a *Api) Extensions() []string {
|
func (a *Api) Extensions() []string {
|
||||||
|
|||||||
44
pkg/modules/libreoffice/api/api_request_attrs_test.go
Normal file
44
pkg/modules/libreoffice/api/api_request_attrs_test.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConversionRequestAttributes(t *testing.T) {
|
||||||
|
tmp := filepath.Join(t.TempDir(), "in.docx")
|
||||||
|
content := []byte("hello world")
|
||||||
|
if err := os.WriteFile(tmp, content, 0o600); err != nil {
|
||||||
|
t.Fatalf("write temp file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
options := Options{
|
||||||
|
Landscape: true,
|
||||||
|
PageRanges: "1-3",
|
||||||
|
PdfFormats: gotenberg.PdfFormats{PdfA: "PDF/A-2b", PdfUa: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := map[string]any{}
|
||||||
|
for _, kv := range conversionRequestAttributes(tmp, options) {
|
||||||
|
got[string(kv.Key)] = kv.Value.AsInterface()
|
||||||
|
}
|
||||||
|
|
||||||
|
if got["gotenberg.libreoffice.pdf_a"] != "PDF/A-2b" {
|
||||||
|
t.Errorf("pdf_a = %v, want PDF/A-2b", got["gotenberg.libreoffice.pdf_a"])
|
||||||
|
}
|
||||||
|
if got["gotenberg.libreoffice.pdf_ua"] != true {
|
||||||
|
t.Errorf("pdf_ua = %v, want true", got["gotenberg.libreoffice.pdf_ua"])
|
||||||
|
}
|
||||||
|
if got["gotenberg.conversion.landscape"] != true {
|
||||||
|
t.Errorf("landscape = %v, want true", got["gotenberg.conversion.landscape"])
|
||||||
|
}
|
||||||
|
if got["gotenberg.conversion.has_page_ranges"] != true {
|
||||||
|
t.Errorf("has_page_ranges = %v, want true", got["gotenberg.conversion.has_page_ranges"])
|
||||||
|
}
|
||||||
|
if got["gotenberg.conversion.input.bytes"] != int64(len(content)) {
|
||||||
|
t.Errorf("input.bytes = %v, want %d", got["gotenberg.conversion.input.bytes"], len(content))
|
||||||
|
}
|
||||||
|
}
|
||||||
84
pkg/modules/libreoffice/api/core_dumped_test.go
Normal file
84
pkg/modules/libreoffice/api/core_dumped_test.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||||
|
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sumInt64Counter(rm metricdata.ResourceMetrics, name string) int64 {
|
||||||
|
var total int64
|
||||||
|
for _, sm := range rm.ScopeMetrics {
|
||||||
|
for _, m := range sm.Metrics {
|
||||||
|
if m.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sum, ok := m.Data.(metricdata.Sum[int64]); ok {
|
||||||
|
for _, dp := range sum.DataPoints {
|
||||||
|
total += dp.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApi_Pdf_CoreDumpedRetryCap(t *testing.T) {
|
||||||
|
reader := sdkmetric.NewManualReader()
|
||||||
|
provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
|
||||||
|
previous := otel.GetMeterProvider()
|
||||||
|
otel.SetMeterProvider(provider)
|
||||||
|
t.Cleanup(func() { otel.SetMeterProvider(previous) })
|
||||||
|
|
||||||
|
var runCalls int
|
||||||
|
supervisor := &gotenberg.ProcessSupervisorMock{
|
||||||
|
RunMock: func(_ context.Context, _ *slog.Logger, _ func() error) error {
|
||||||
|
runCalls++
|
||||||
|
return ErrCoreDumped
|
||||||
|
},
|
||||||
|
ReqQueueSizeMock: func() int64 { return 0 },
|
||||||
|
ConversionsSinceRestartMock: func() int64 { return 0 },
|
||||||
|
}
|
||||||
|
|
||||||
|
a := &Api{supervisor: supervisor}
|
||||||
|
meter := gotenberg.Meter()
|
||||||
|
a.reqsCounter, _ = meter.Int64Counter("libreoffice.requests.total")
|
||||||
|
a.errsCounter, _ = meter.Int64Counter("libreoffice.errors.total")
|
||||||
|
a.conversionDurationCounter, _ = meter.Float64Histogram("libreoffice.conversion.duration")
|
||||||
|
a.queueWaitDurationCounter, _ = meter.Float64Histogram("libreoffice.queue.wait.duration")
|
||||||
|
a.pdfOutputSizeCounter, _ = meter.Int64Histogram("libreoffice.pdf.output.size")
|
||||||
|
a.coreDumpedRetriesCounter, _ = meter.Int64Counter("libreoffice.conversion.retries.total")
|
||||||
|
|
||||||
|
err := a.Pdf(context.Background(), slog.New(slog.DiscardHandler), "/nonexistent/in.docx", "/tmp/out.pdf", Options{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error after exhausting the retries")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrCoreDumped) {
|
||||||
|
t.Errorf("expected ErrCoreDumped, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 initial attempt + 10 retries.
|
||||||
|
if runCalls != 11 {
|
||||||
|
t.Errorf("supervisor.Run called %d times, want 11", runCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rm metricdata.ResourceMetrics
|
||||||
|
if err := reader.Collect(context.Background(), &rm); err != nil {
|
||||||
|
t.Fatalf("collect: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if retries := sumInt64Counter(rm, "libreoffice.conversion.retries.total"); retries != 10 {
|
||||||
|
t.Errorf("retries counter = %d, want 10", retries)
|
||||||
|
}
|
||||||
|
// Per-attempt request metric must be preserved: one per attempt.
|
||||||
|
if reqs := sumInt64Counter(rm, "libreoffice.requests.total"); reqs != 11 {
|
||||||
|
t.Errorf("requests counter = %d, want 11", reqs)
|
||||||
|
}
|
||||||
|
}
|
||||||
33
pkg/modules/libreoffice/api/errortype_test.go
Normal file
33
pkg/modules/libreoffice/api/errortype_test.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLibreofficeErrorType(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"deadline", context.DeadlineExceeded, "timeout"},
|
||||||
|
{"canceled", context.Canceled, "context_cancelled"},
|
||||||
|
{"invalid pdf formats", ErrInvalidPdfFormats, "invalid_input"},
|
||||||
|
{"uno exception", ErrUnoException, "libreoffice_exception"},
|
||||||
|
{"runtime exception", ErrRuntimeException, "libreoffice_exception"},
|
||||||
|
{"queue size exceeded", gotenberg.ErrMaximumQueueSizeExceeded, "libreoffice_unavailable"},
|
||||||
|
{"process restarting", gotenberg.ErrProcessAlreadyRestarting, "libreoffice_unavailable"},
|
||||||
|
{"core dumped", ErrCoreDumped, "unknown"},
|
||||||
|
{"unknown", errors.New("boom"), "unknown"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := libreofficeErrorType(tc.err); got != tc.want {
|
||||||
|
t.Errorf("libreofficeErrorType(%v) = %q, want %q", tc.err, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -69,11 +70,13 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
|
|||||||
|
|
||||||
// LibreOffice fetches external content (OOXML images via
|
// LibreOffice fetches external content (OOXML images via
|
||||||
// TargetMode=External, RTF INCLUDEPICTURE, ODT linked images) inside
|
// TargetMode=External, RTF INCLUDEPICTURE, ODT linked images) inside
|
||||||
// its own libcurl. Route those fetches through the in-process proxy
|
// its own libcurl. The profile config routes those fetches through the
|
||||||
// so the chromium/webhook SSRF filters apply.
|
// in-process proxy so the chromium/webhook SSRF filters apply, and
|
||||||
if err := writeSofficeProxyConfig(userProfileDirPath, proxy.Addr()); err != nil {
|
// blocks content linked from untrusted locations so absolute-path
|
||||||
|
// (file://) and direct fetches are dropped at the source.
|
||||||
|
if err := writeSofficeProfileConfig(userProfileDirPath, proxy.Addr()); err != nil {
|
||||||
_ = proxy.Stop(context.Background())
|
_ = proxy.Stop(context.Background())
|
||||||
return fmt.Errorf("write soffice proxy config: %w", err)
|
return fmt.Errorf("write soffice profile config: %w", err)
|
||||||
}
|
}
|
||||||
sofficeEnv := sofficeProxyEnv(os.Environ(), proxy.Addr())
|
sofficeEnv := sofficeProxyEnv(os.Environ(), proxy.Addr())
|
||||||
|
|
||||||
@@ -195,7 +198,7 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
|
|||||||
select {
|
select {
|
||||||
case err = <-connChan:
|
case err = <-connChan:
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("LibreOffice socket not available: %w", err)
|
return fmt.Errorf("LibreOffice did not become available within the start timeout; increase --libreoffice-start-timeout or check system resources: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugContext(context.Background(), "LibreOffice socket available")
|
logger.DebugContext(context.Background(), "LibreOffice socket available")
|
||||||
@@ -203,7 +206,7 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
case err = <-waitChan:
|
case err = <-waitChan:
|
||||||
return fmt.Errorf("LibreOffice process exited: %w", err)
|
return fmt.Errorf("LibreOffice exited unexpectedly during startup; check system resources such as memory, disk, and permissions: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,6 +322,16 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, input
|
|||||||
args = append(args, "--disable-update-indexes")
|
args = append(args, "--disable-update-indexes")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A CSV becomes a single Calc sheet named after the input file, and Calc's
|
||||||
|
// default page style prints that sheet name as a centered header. Uploads
|
||||||
|
// are stored under a UUID-based filename, so the UUID would otherwise leak
|
||||||
|
// into the rendered PDF. Suppress the header for CSV inputs; spreadsheets
|
||||||
|
// that carry their own page styles (XLSX, ODS) are left untouched.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1568.
|
||||||
|
if strings.EqualFold(filepath.Ext(inputPath), ".csv") {
|
||||||
|
args = append(args, "--disable-calc-header")
|
||||||
|
}
|
||||||
|
|
||||||
args = append(args, "--export", fmt.Sprintf("ExportFormFields=%t", options.ExportFormFields))
|
args = append(args, "--export", fmt.Sprintf("ExportFormFields=%t", options.ExportFormFields))
|
||||||
args = append(args, "--export", fmt.Sprintf("AllowDuplicateFieldNames=%t", options.AllowDuplicateFieldNames))
|
args = append(args, "--export", fmt.Sprintf("AllowDuplicateFieldNames=%t", options.AllowDuplicateFieldNames))
|
||||||
args = append(args, "--export", fmt.Sprintf("ExportBookmarks=%t", options.ExportBookmarks))
|
args = append(args, "--export", fmt.Sprintf("ExportBookmarks=%t", options.ExportBookmarks))
|
||||||
|
|||||||
@@ -15,16 +15,18 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dlclark/regexp2"
|
"github.com/dlclark/regexp2"
|
||||||
|
"golang.org/x/net/http/httpproxy"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// outboundProxyOptions configures a [libreOfficeProxy].
|
// outboundProxyOptions configures a [libreOfficeProxy].
|
||||||
type outboundProxyOptions struct {
|
type outboundProxyOptions struct {
|
||||||
allowList []*regexp2.Regexp
|
allowList []*regexp2.Regexp
|
||||||
denyList []*regexp2.Regexp
|
denyList []*regexp2.Regexp
|
||||||
denyPrivateIPs bool
|
denyPrivateIPs bool
|
||||||
denyPublicIPs bool
|
denyPublicIPs bool
|
||||||
|
enableEnvironmentProxy bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// libreOfficeProxy is an HTTP/HTTPS forward proxy that LibreOffice routes
|
// libreOfficeProxy is an HTTP/HTTPS forward proxy that LibreOffice routes
|
||||||
@@ -45,6 +47,12 @@ type libreOfficeProxy struct {
|
|||||||
opts outboundProxyOptions
|
opts outboundProxyOptions
|
||||||
logger *slog.Logger
|
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
|
stopOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +72,15 @@ func newLibreOfficeProxy(logger *slog.Logger, opts outboundProxyOptions) (*libre
|
|||||||
|
|
||||||
p := &libreOfficeProxy{
|
p := &libreOfficeProxy{
|
||||||
listener: listener,
|
listener: listener,
|
||||||
client: gotenberg.NewOutboundHttpClient(0, opts.allowList, opts.denyList, decideOpts...),
|
client: gotenberg.NewOutboundHttpClient(0, opts.allowList, opts.denyList, opts.enableEnvironmentProxy, decideOpts...),
|
||||||
opts: opts,
|
opts: opts,
|
||||||
logger: logger.With(slog.String("logger", "libreoffice-proxy")),
|
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{
|
p.server = &http.Server{
|
||||||
Handler: p,
|
Handler: p,
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
@@ -182,8 +195,25 @@ func (p *libreOfficeProxy) handleConnect(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
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
|
var dest net.Conn
|
||||||
switch {
|
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:
|
case len(decision.Pinned) > 0:
|
||||||
dest, err = gotenberg.DialPinned(r.Context(), "tcp", decision.Pinned, port)
|
dest, err = gotenberg.DialPinned(r.Context(), "tcp", decision.Pinned, port)
|
||||||
default:
|
default:
|
||||||
@@ -245,13 +275,26 @@ var hopByHopHeaders = []string{
|
|||||||
"Upgrade",
|
"Upgrade",
|
||||||
}
|
}
|
||||||
|
|
||||||
// sofficeProxyConfigTmpl is the registrymodifications.xcu fragment that
|
// sofficeProfileConfigTmpl is the registrymodifications.xcu the soffice
|
||||||
// tells soffice's UCB layer to route every HTTP and HTTPS fetch through
|
// daemon loads at startup. It does two things:
|
||||||
// proxyHost:proxyPort. The %s placeholders accept the proxy host and
|
//
|
||||||
// port respectively (host first, port second, repeated for HTTP and
|
// 1. Routes every HTTP and HTTPS fetch through proxyHost:proxyPort so
|
||||||
// HTTPS).
|
// soffice's own libcurl fetches hit the in-process SSRF proxy.
|
||||||
const sofficeProxyConfigTmpl = `<?xml version="1.0" encoding="UTF-8"?>
|
// 2. Sets BlockUntrustedRefererLinks so soffice refuses to load content
|
||||||
|
// linked from a document that sits in an untrusted location.
|
||||||
|
//
|
||||||
|
// The second setting closes the local-read and direct-fetch vectors the
|
||||||
|
// proxy cannot see. A document that links an absolute path
|
||||||
|
// (file:///etc/...) or any URL is loaded from the per-request temp dir,
|
||||||
|
// which is never a trusted location, so soffice drops the linked content
|
||||||
|
// instead of resolving it. Embedded content (stored inside the document)
|
||||||
|
// is unaffected.
|
||||||
|
//
|
||||||
|
// The %s placeholders accept the proxy host and port respectively (host
|
||||||
|
// first, port second, repeated for HTTP and HTTPS).
|
||||||
|
const sofficeProfileConfigTmpl = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<oor:items xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
<oor:items xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||||
|
<item oor:path="/org.openoffice.Office.Common/Security/Scripting"><prop oor:name="BlockUntrustedRefererLinks" oor:op="fuse"><value>true</value></prop></item>
|
||||||
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetProxyType" oor:op="fuse"><value>1</value></prop></item>
|
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetProxyType" oor:op="fuse"><value>1</value></prop></item>
|
||||||
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetHTTPProxyName" oor:op="fuse"><value>%s</value></prop></item>
|
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetHTTPProxyName" oor:op="fuse"><value>%s</value></prop></item>
|
||||||
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetHTTPProxyPort" oor:op="fuse"><value>%s</value></prop></item>
|
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetHTTPProxyPort" oor:op="fuse"><value>%s</value></prop></item>
|
||||||
@@ -261,10 +304,11 @@ const sofficeProxyConfigTmpl = `<?xml version="1.0" encoding="UTF-8"?>
|
|||||||
</oor:items>
|
</oor:items>
|
||||||
`
|
`
|
||||||
|
|
||||||
// writeSofficeProxyConfig drops a registrymodifications.xcu file into
|
// writeSofficeProfileConfig drops a registrymodifications.xcu file into
|
||||||
// userProfileDirPath/user/ that points soffice's UCB layer at proxyAddr
|
// userProfileDirPath/user/ that points soffice's UCB layer at proxyAddr
|
||||||
// for both HTTP and HTTPS. proxyAddr must be a host:port pair.
|
// for both HTTP and HTTPS and blocks linked content from untrusted
|
||||||
func writeSofficeProxyConfig(userProfileDirPath, proxyAddr string) error {
|
// locations. proxyAddr must be a host:port pair.
|
||||||
|
func writeSofficeProfileConfig(userProfileDirPath, proxyAddr string) error {
|
||||||
host, port, err := net.SplitHostPort(proxyAddr)
|
host, port, err := net.SplitHostPort(proxyAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("split proxy address %q: %w", proxyAddr, err)
|
return fmt.Errorf("split proxy address %q: %w", proxyAddr, err)
|
||||||
@@ -276,7 +320,7 @@ func writeSofficeProxyConfig(userProfileDirPath, proxyAddr string) error {
|
|||||||
return fmt.Errorf("create soffice user profile directory: %w", err)
|
return fmt.Errorf("create soffice user profile directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
body := fmt.Sprintf(sofficeProxyConfigTmpl, host, port, host, port)
|
body := fmt.Sprintf(sofficeProfileConfigTmpl, host, port, host, port)
|
||||||
err = os.WriteFile(userDir+"/registrymodifications.xcu", []byte(body), 0o600)
|
err = os.WriteFile(userDir+"/registrymodifications.xcu", []byte(body), 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("write registrymodifications.xcu: %w", err)
|
return fmt.Errorf("write registrymodifications.xcu: %w", err)
|
||||||
|
|||||||
@@ -280,11 +280,11 @@ func TestLibreOfficeProxy_StopIsIdempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWriteSofficeProxyConfig(t *testing.T) {
|
func TestWriteSofficeProfileConfig(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
||||||
if err := writeSofficeProxyConfig(dir, "127.0.0.1:9876"); err != nil {
|
if err := writeSofficeProfileConfig(dir, "127.0.0.1:9876"); err != nil {
|
||||||
t.Fatalf("writeSofficeProxyConfig: %v", err)
|
t.Fatalf("writeSofficeProfileConfig: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := os.ReadFile(filepath.Join(dir, "user", "registrymodifications.xcu"))
|
body, err := os.ReadFile(filepath.Join(dir, "user", "registrymodifications.xcu"))
|
||||||
@@ -297,6 +297,9 @@ func TestWriteSofficeProxyConfig(t *testing.T) {
|
|||||||
`ooInetHTTPProxyName`, `<value>127.0.0.1</value>`,
|
`ooInetHTTPProxyName`, `<value>127.0.0.1</value>`,
|
||||||
`ooInetHTTPProxyPort`, `<value>9876</value>`,
|
`ooInetHTTPProxyPort`, `<value>9876</value>`,
|
||||||
`ooInetHTTPSProxyName`, `ooInetHTTPSProxyPort`,
|
`ooInetHTTPSProxyName`, `ooInetHTTPSProxyPort`,
|
||||||
|
// Blocks linked content from untrusted locations, closing the
|
||||||
|
// file:// local-read and direct-fetch vectors the proxy cannot see.
|
||||||
|
`BlockUntrustedRefererLinks`, `<value>true</value>`,
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(string(body), want) {
|
if !strings.Contains(string(body), want) {
|
||||||
t.Errorf("xcu missing %q\nfull body:\n%s", want, body)
|
t.Errorf("xcu missing %q\nfull body:\n%s", want, body)
|
||||||
@@ -304,8 +307,8 @@ func TestWriteSofficeProxyConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWriteSofficeProxyConfig_InvalidAddr(t *testing.T) {
|
func TestWriteSofficeProfileConfig_InvalidAddr(t *testing.T) {
|
||||||
err := writeSofficeProxyConfig(t.TempDir(), "not-a-host-port")
|
err := writeSofficeProfileConfig(t.TempDir(), "not-a-host-port")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for malformed proxy address")
|
t.Fatal("expected error for malformed proxy address")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ func (engine *LibreOfficePdfEngine) ReadBookmarks(ctx context.Context, logger *s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt is not available in this implementation.
|
// Encrypt is not available in this implementation.
|
||||||
func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
|
func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
|
||||||
return fmt.Errorf("encrypt PDF using LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
return fmt.Errorf("encrypt PDF using LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +135,16 @@ func (engine *LibreOfficePdfEngine) Rotate(ctx context.Context, logger *slog.Log
|
|||||||
return fmt.Errorf("rotate PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
return fmt.Errorf("rotate PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InjectFacturXXMP is not available in this implementation.
|
||||||
|
func (engine *LibreOfficePdfEngine) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error {
|
||||||
|
return fmt.Errorf("inject Factur-X XMP with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadPdfAConformance is not available in this implementation.
|
||||||
|
func (engine *LibreOfficePdfEngine) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
|
||||||
|
return "", "", fmt.Errorf("read PDF/A conformance with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
// Interface guards.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ gotenberg.Module = (*LibreOfficePdfEngine)(nil)
|
_ gotenberg.Module = (*LibreOfficePdfEngine)(nil)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
splitMode := pdfengines.FormDataPdfSplitMode(form, false)
|
splitMode := pdfengines.FormDataPdfSplitMode(form, false)
|
||||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||||
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
||||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||||
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
||||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
||||||
@@ -38,6 +38,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||||
angle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
angle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||||
|
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||||
|
|
||||||
zeroValuedSplitMode := gotenberg.SplitMode{}
|
zeroValuedSplitMode := gotenberg.SplitMode{}
|
||||||
|
|
||||||
@@ -313,13 +314,27 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
return fmt.Errorf("validate stamp: %w", err)
|
return fmt.Errorf("validate stamp: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
|
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = pdfengines.ValidatePdfEncryptCompat(encrypt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pdfengines.ValidateFacturXCompat(facturX, facturxXmlPath, pdfFormats)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factur-X requires PDF/A-3; default to PDF/A-3b when no format was
|
||||||
|
// requested. The conversion runs as a post-processing step below.
|
||||||
|
pdfFormats = pdfengines.FacturXPdfFormats(ctx, engine, facturX, pdfFormats, true, nil)
|
||||||
|
|
||||||
hasPostProcessing := watermark.Source != "" || stamp.Source != "" || angle != 0 ||
|
hasPostProcessing := watermark.Source != "" || stamp.Source != "" || angle != 0 ||
|
||||||
len(embedPaths) > 0 || len(metadata) > 0 || flatten
|
len(embedPaths) > 0 || len(metadata) > 0 || flatten || facturX.ConformanceLevel != ""
|
||||||
|
|
||||||
outputPaths := make([]string, len(inputPaths))
|
outputPaths := make([]string, len(inputPaths))
|
||||||
for i, inputPath := range inputPaths {
|
for i, inputPath := range inputPaths {
|
||||||
@@ -385,7 +400,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
fmt.Errorf("convert to PDF: %w", err),
|
fmt.Errorf("convert to PDF: %w", err),
|
||||||
api.NewSentinelHttpError(
|
api.NewSentinelHttpError(
|
||||||
http.StatusBadRequest,
|
http.StatusBadRequest,
|
||||||
fmt.Sprintf("A PDF format in '%+v' is not supported", pdfFormats),
|
fmt.Sprintf("The PDF format '%s' is not supported. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.", pdfFormats.PdfA),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -503,7 +518,12 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
return fmt.Errorf("set embeds metadata: %w", err)
|
return fmt.Errorf("set embeds metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
|
err = pdfengines.ApplyFacturXStub(ctx, engine, facturX, facturxXmlPath, outputPaths)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("apply Factur-X: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pdfengines.EncryptPdfStub(ctx, engine, encrypt, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.opentelemetry.io/otel/codes"
|
"go.opentelemetry.io/otel/codes"
|
||||||
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
||||||
"go.opentelemetry.io/otel/trace"
|
"go.opentelemetry.io/otel/trace"
|
||||||
@@ -30,6 +32,9 @@ func init() {
|
|||||||
// [gotenberg.PdfEngine] interface.
|
// [gotenberg.PdfEngine] interface.
|
||||||
type PdfCpu struct {
|
type PdfCpu struct {
|
||||||
binPath string
|
binPath string
|
||||||
|
|
||||||
|
version string
|
||||||
|
versionOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
type pdfcpuBookmark struct {
|
type pdfcpuBookmark struct {
|
||||||
@@ -74,35 +79,60 @@ func (engine *PdfCpu) Validate() error {
|
|||||||
|
|
||||||
// Debug returns additional debug data.
|
// Debug returns additional debug data.
|
||||||
func (engine *PdfCpu) Debug() map[string]any {
|
func (engine *PdfCpu) Debug() map[string]any {
|
||||||
debug := make(map[string]any)
|
return map[string]any{"version": engine.detectVersion()}
|
||||||
|
}
|
||||||
|
|
||||||
cmd := exec.Command(engine.binPath, "version") //nolint:gosec
|
// detectVersion resolves the pdfcpu version once, preferring the value captured
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
// at image build time so it never spawns pdfcpu at runtime. It falls back to
|
||||||
|
// running pdfcpu version for local or non-Docker builds.
|
||||||
output, err := cmd.Output()
|
func (engine *PdfCpu) detectVersion() string {
|
||||||
if err != nil {
|
engine.versionOnce.Do(func() {
|
||||||
debug["version"] = err.Error()
|
if v, ok := gotenberg.BuildVersion("pdfcpu"); ok {
|
||||||
return debug
|
engine.version = v
|
||||||
}
|
return
|
||||||
|
|
||||||
debug["version"] = "Unable to determine pdfcpu version"
|
|
||||||
|
|
||||||
lines := strings.SplitSeq(string(output), "\n")
|
|
||||||
for line := range lines {
|
|
||||||
if after, ok := strings.CutPrefix(line, "pdfcpu:"); ok {
|
|
||||||
debug["version"] = strings.TrimSpace(after)
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(engine.binPath, "version") //nolint:gosec
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
engine.version = err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.version = "Unable to determine pdfcpu version"
|
||||||
|
|
||||||
|
lines := strings.SplitSeq(string(output), "\n")
|
||||||
|
for line := range lines {
|
||||||
|
if after, ok := strings.CutPrefix(line, "pdfcpu:"); ok {
|
||||||
|
engine.version = strings.TrimSpace(after)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return engine.version
|
||||||
|
}
|
||||||
|
|
||||||
|
// spanAttrs returns the client-span attributes for a pdfcpu invocation: the
|
||||||
|
// server address and the pdfcpu version, plus any extra attributes. The version
|
||||||
|
// rides on every span so a trace records which pdfcpu ran the operation.
|
||||||
|
func (engine *PdfCpu) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue {
|
||||||
|
attrs := make([]attribute.KeyValue, 0, 2+len(extra))
|
||||||
|
attrs = append(attrs, semconv.ServerAddress(engine.binPath))
|
||||||
|
if v := engine.detectVersion(); v != "" {
|
||||||
|
attrs = append(attrs, attribute.String("gotenberg.pdfcpu.version", v))
|
||||||
}
|
}
|
||||||
|
|
||||||
return debug
|
return append(attrs, extra...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge combines multiple PDFs into a single PDF.
|
// Merge combines multiple PDFs into a single PDF.
|
||||||
func (engine *PdfCpu) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
func (engine *PdfCpu) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Merge",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Merge",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -134,7 +164,7 @@ func (engine *PdfCpu) Merge(ctx context.Context, logger *slog.Logger, inputPaths
|
|||||||
func (engine *PdfCpu) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
func (engine *PdfCpu) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Split",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Split",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -203,7 +233,7 @@ func (engine *PdfCpu) Split(ctx context.Context, logger *slog.Logger, mode goten
|
|||||||
func (engine *PdfCpu) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
|
func (engine *PdfCpu) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Flatten",
|
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Flatten",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -217,7 +247,7 @@ func (engine *PdfCpu) Flatten(ctx context.Context, logger *slog.Logger, inputPat
|
|||||||
func (engine *PdfCpu) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
func (engine *PdfCpu) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Convert",
|
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Convert",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -231,7 +261,7 @@ func (engine *PdfCpu) Convert(ctx context.Context, logger *slog.Logger, formats
|
|||||||
func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.ReadMetadata",
|
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.ReadMetadata",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -245,7 +275,7 @@ func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *slog.Logger, inp
|
|||||||
func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
|
func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.WriteMetadata",
|
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.WriteMetadata",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -259,7 +289,7 @@ func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *slog.Logger, me
|
|||||||
func (engine *PdfCpu) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
|
func (engine *PdfCpu) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.PageCount",
|
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.PageCount",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -273,7 +303,7 @@ func (engine *PdfCpu) PageCount(ctx context.Context, logger *slog.Logger, inputP
|
|||||||
func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
|
func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.ReadBookmarks",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.ReadBookmarks",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -376,7 +406,7 @@ func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *slog.Logger, in
|
|||||||
func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
|
func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.WriteBookmarks",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.WriteBookmarks",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -452,12 +482,22 @@ func (engine *PdfCpu) EmbedFilesMetadata(ctx context.Context, logger *slog.Logge
|
|||||||
return fmt.Errorf("set embeds metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
return fmt.Errorf("set embeds metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InjectFacturXXMP is not available in this implementation.
|
||||||
|
func (engine *PdfCpu) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error {
|
||||||
|
return fmt.Errorf("inject Factur-X XMP with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadPdfAConformance is not available in this implementation.
|
||||||
|
func (engine *PdfCpu) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
|
||||||
|
return "", "", fmt.Errorf("read PDF/A conformance with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
|
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
|
||||||
// without modifying the main PDF content.
|
// without modifying the main PDF content.
|
||||||
func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.EmbedFiles",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.EmbedFiles",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -493,30 +533,38 @@ func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *slog.Logger, fileP
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt adds password protection to a PDF file using pdfcpu.
|
// Encrypt adds password protection to a PDF file using pdfcpu.
|
||||||
func (engine *PdfCpu) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
|
func (engine *PdfCpu) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Encrypt",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Encrypt",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
if userPassword == "" {
|
ownerPassword := opts.OwnerPassword
|
||||||
err := errors.New("user password cannot be empty")
|
if ownerPassword == "" {
|
||||||
|
ownerPassword = opts.UserPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty user password is allowed: it produces an owner-only document.
|
||||||
|
if opts.UserPassword == "" && ownerPassword == "" {
|
||||||
|
err := errors.New("at least a user or owner password is required")
|
||||||
span.RecordError(err)
|
span.RecordError(err)
|
||||||
span.SetStatus(codes.Error, err.Error())
|
span.SetStatus(codes.Error, err.Error())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if ownerPassword == "" {
|
// pdfcpu only supports coarse permissions: all actions or none.
|
||||||
ownerPassword = userPassword
|
perm := "all"
|
||||||
|
if opts.Permissions.Restricted() {
|
||||||
|
perm = "none"
|
||||||
}
|
}
|
||||||
|
|
||||||
args := make([]string, 0, 11)
|
args := make([]string, 0, 11)
|
||||||
args = append(args, "encrypt")
|
args = append(args, "encrypt")
|
||||||
args = append(args, "--mode", "aes")
|
args = append(args, "--mode", "aes")
|
||||||
args = append(args, "--upw", userPassword)
|
args = append(args, "--upw", opts.UserPassword)
|
||||||
args = append(args, "--opw", ownerPassword)
|
args = append(args, "--opw", ownerPassword)
|
||||||
args = append(args, "--perm", "all")
|
args = append(args, "--perm", perm)
|
||||||
args = append(args, inputPath, inputPath)
|
args = append(args, inputPath, inputPath)
|
||||||
|
|
||||||
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
||||||
@@ -543,7 +591,7 @@ func (engine *PdfCpu) Encrypt(ctx context.Context, logger *slog.Logger, inputPat
|
|||||||
func (engine *PdfCpu) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
func (engine *PdfCpu) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Watermark",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Watermark",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -562,7 +610,7 @@ func (engine *PdfCpu) Watermark(ctx context.Context, logger *slog.Logger, inputP
|
|||||||
func (engine *PdfCpu) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
func (engine *PdfCpu) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Stamp",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Stamp",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -581,7 +629,7 @@ func (engine *PdfCpu) Stamp(ctx context.Context, logger *slog.Logger, inputPath
|
|||||||
func (engine *PdfCpu) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
|
func (engine *PdfCpu) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Rotate",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Rotate",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
|
|||||||
169
pkg/modules/pdfengines/fallback_test.go
Normal file
169
pkg/modules/pdfengines/fallback_test.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
package pdfengines
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
"go.opentelemetry.io/otel/codes"
|
||||||
|
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||||
|
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newFallbackRecorder(t *testing.T) *tracetest.SpanRecorder {
|
||||||
|
t.Helper()
|
||||||
|
recorder := tracetest.NewSpanRecorder()
|
||||||
|
provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
|
||||||
|
previous := otel.GetTracerProvider()
|
||||||
|
otel.SetTracerProvider(provider)
|
||||||
|
t.Cleanup(func() { otel.SetTracerProvider(previous) })
|
||||||
|
return recorder
|
||||||
|
}
|
||||||
|
|
||||||
|
func findFallbackSpan(recorder *tracetest.SpanRecorder, name string) sdktrace.ReadOnlySpan {
|
||||||
|
for _, s := range recorder.Ended() {
|
||||||
|
if s.Name() == name {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapTest(err error) error { return fmt.Errorf("test op with multi PDF engines: %w", err) }
|
||||||
|
|
||||||
|
func TestRunWithFallback_FirstSucceeds(t *testing.T) {
|
||||||
|
recorder := newFallbackRecorder(t)
|
||||||
|
engines := []gotenberg.PdfEngine{&gotenberg.PdfEngineMock{}, &gotenberg.PdfEngineMock{}}
|
||||||
|
|
||||||
|
calls := 0
|
||||||
|
got, err := runWithFallback(context.Background(), "pdfengines.Test", engines,
|
||||||
|
func(_ context.Context, _ gotenberg.PdfEngine) (string, error) {
|
||||||
|
calls++
|
||||||
|
return "ok", nil
|
||||||
|
}, wrapTest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != "ok" {
|
||||||
|
t.Errorf("got %q, want ok", got)
|
||||||
|
}
|
||||||
|
if calls != 1 {
|
||||||
|
t.Errorf("expected 1 engine call, got %d", calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
span := findFallbackSpan(recorder, "pdfengines.Test")
|
||||||
|
if span.Status().Code != codes.Ok {
|
||||||
|
t.Errorf("status = %v, want Ok", span.Status().Code)
|
||||||
|
}
|
||||||
|
attrs := map[string]string{}
|
||||||
|
for _, kv := range span.Attributes() {
|
||||||
|
attrs[string(kv.Key)] = kv.Value.Emit()
|
||||||
|
}
|
||||||
|
if attrs["gotenberg.pdf_engine.attempts"] != "1" {
|
||||||
|
t.Errorf("attempts = %q, want 1", attrs["gotenberg.pdf_engine.attempts"])
|
||||||
|
}
|
||||||
|
if attrs["gotenberg.pdf_engine.selected"] == "" {
|
||||||
|
t.Error("expected a selected engine attribute")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunWithFallback_SecondSucceeds(t *testing.T) {
|
||||||
|
recorder := newFallbackRecorder(t)
|
||||||
|
engines := []gotenberg.PdfEngine{&gotenberg.PdfEngineMock{}, &gotenberg.PdfEngineMock{}}
|
||||||
|
|
||||||
|
calls := 0
|
||||||
|
got, err := runWithFallback(context.Background(), "pdfengines.Test", engines,
|
||||||
|
func(_ context.Context, _ gotenberg.PdfEngine) (string, error) {
|
||||||
|
calls++
|
||||||
|
if calls == 1 {
|
||||||
|
return "", errors.New("first engine failed")
|
||||||
|
}
|
||||||
|
return "ok", nil
|
||||||
|
}, wrapTest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != "ok" || calls != 2 {
|
||||||
|
t.Errorf("got %q after %d calls, want ok after 2", got, calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
span := findFallbackSpan(recorder, "pdfengines.Test")
|
||||||
|
var failedEvents int
|
||||||
|
for _, e := range span.Events() {
|
||||||
|
if e.Name == "pdf_engine.attempt_failed" {
|
||||||
|
failedEvents++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if failedEvents != 1 {
|
||||||
|
t.Errorf("expected 1 attempt_failed event, got %d", failedEvents)
|
||||||
|
}
|
||||||
|
for _, kv := range span.Attributes() {
|
||||||
|
if string(kv.Key) == "gotenberg.pdf_engine.attempts" && kv.Value.Emit() != "2" {
|
||||||
|
t.Errorf("attempts = %q, want 2", kv.Value.Emit())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunWithFallback_AllFail(t *testing.T) {
|
||||||
|
recorder := newFallbackRecorder(t)
|
||||||
|
engines := []gotenberg.PdfEngine{&gotenberg.PdfEngineMock{}, &gotenberg.PdfEngineMock{}}
|
||||||
|
|
||||||
|
sentinel := errors.New("engine failed")
|
||||||
|
_, err := runWithFallback(context.Background(), "pdfengines.Test", engines,
|
||||||
|
func(_ context.Context, _ gotenberg.PdfEngine) (string, error) {
|
||||||
|
return "", sentinel
|
||||||
|
}, wrapTest)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error when all engines fail")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, sentinel) {
|
||||||
|
t.Errorf("expected the joined engine error to be wrapped, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
span := findFallbackSpan(recorder, "pdfengines.Test")
|
||||||
|
if span.Status().Code != codes.Error {
|
||||||
|
t.Errorf("status = %v, want Error", span.Status().Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunWithFallback_ZeroEngines(t *testing.T) {
|
||||||
|
newFallbackRecorder(t)
|
||||||
|
_, err := runWithFallback(context.Background(), "pdfengines.Test", nil,
|
||||||
|
func(_ context.Context, _ gotenberg.PdfEngine) (string, error) {
|
||||||
|
return "ok", nil
|
||||||
|
}, wrapTest)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected an error with no engines")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunWithFallback_ContextDone(t *testing.T) {
|
||||||
|
recorder := newFallbackRecorder(t)
|
||||||
|
engines := []gotenberg.PdfEngine{&gotenberg.PdfEngineMock{}}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
release := make(chan struct{})
|
||||||
|
t.Cleanup(func() { close(release) })
|
||||||
|
|
||||||
|
_, err := runWithFallback(ctx, "pdfengines.Test", engines,
|
||||||
|
func(_ context.Context, _ gotenberg.PdfEngine) (string, error) {
|
||||||
|
<-release // never returns during the call, forcing the ctx.Done branch
|
||||||
|
return "", nil
|
||||||
|
}, wrapTest)
|
||||||
|
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf("expected context.Canceled, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
span := findFallbackSpan(recorder, "pdfengines.Test")
|
||||||
|
if span.Status().Code != codes.Error {
|
||||||
|
t.Errorf("status = %v, want Error (the cancellation must mark the span)", span.Status().Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"sync"
|
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.opentelemetry.io/otel/codes"
|
"go.opentelemetry.io/otel/codes"
|
||||||
"go.opentelemetry.io/otel/trace"
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ type multiPdfEngines struct {
|
|||||||
watermarkEngines []gotenberg.PdfEngine
|
watermarkEngines []gotenberg.PdfEngine
|
||||||
stampEngines []gotenberg.PdfEngine
|
stampEngines []gotenberg.PdfEngine
|
||||||
rotateEngines []gotenberg.PdfEngine
|
rotateEngines []gotenberg.PdfEngine
|
||||||
|
facturXEngines []gotenberg.PdfEngine
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMultiPdfEngines(
|
func newMultiPdfEngines(
|
||||||
@@ -44,7 +45,8 @@ func newMultiPdfEngines(
|
|||||||
writeBookmarksEngines,
|
writeBookmarksEngines,
|
||||||
watermarkEngines,
|
watermarkEngines,
|
||||||
stampEngines,
|
stampEngines,
|
||||||
rotateEngines []gotenberg.PdfEngine,
|
rotateEngines,
|
||||||
|
facturXEngines []gotenberg.PdfEngine,
|
||||||
) *multiPdfEngines {
|
) *multiPdfEngines {
|
||||||
return &multiPdfEngines{
|
return &multiPdfEngines{
|
||||||
mergeEngines: mergeEngines,
|
mergeEngines: mergeEngines,
|
||||||
@@ -61,586 +63,292 @@ func newMultiPdfEngines(
|
|||||||
watermarkEngines: watermarkEngines,
|
watermarkEngines: watermarkEngines,
|
||||||
stampEngines: stampEngines,
|
stampEngines: stampEngines,
|
||||||
rotateEngines: rotateEngines,
|
rotateEngines: rotateEngines,
|
||||||
|
facturXEngines: facturXEngines,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// engineName returns the module ID of a PDF engine for telemetry, falling back
|
||||||
|
// to its type name when it does not expose a descriptor.
|
||||||
|
func engineName(engine gotenberg.PdfEngine) string {
|
||||||
|
if module, ok := engine.(gotenberg.Module); ok {
|
||||||
|
return module.Descriptor().ID
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%T", engine)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runWithFallback runs op against each engine in order and returns the first
|
||||||
|
// success. It wraps the attempts in a pdfengines span, records the winning
|
||||||
|
// engine and attempt count, emits a pdf_engine.attempt_failed event for each
|
||||||
|
// failed engine, and joins all engine errors on total failure. A context
|
||||||
|
// cancellation marks the span as errored too. wrap applies the op-specific
|
||||||
|
// final error message.
|
||||||
|
func runWithFallback[T any](
|
||||||
|
ctx context.Context,
|
||||||
|
spanName string,
|
||||||
|
engines []gotenberg.PdfEngine,
|
||||||
|
op func(ctx context.Context, engine gotenberg.PdfEngine) (T, error),
|
||||||
|
wrap func(err error) error,
|
||||||
|
) (T, error) {
|
||||||
|
var zero T
|
||||||
|
|
||||||
|
ctx, span := gotenberg.Tracer().Start(ctx, spanName, trace.WithSpanKind(trace.SpanKindInternal))
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
type attemptResult struct {
|
||||||
|
value T
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
var joined error
|
||||||
|
for attempt, engine := range engines {
|
||||||
|
resultChan := make(chan attemptResult, 1)
|
||||||
|
|
||||||
|
go func(engine gotenberg.PdfEngine) {
|
||||||
|
value, err := op(ctx, engine)
|
||||||
|
resultChan <- attemptResult{value: value, err: err}
|
||||||
|
}(engine)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case result := <-resultChan:
|
||||||
|
if result.err == nil {
|
||||||
|
span.SetAttributes(
|
||||||
|
attribute.String("gotenberg.pdf_engine.selected", engineName(engine)),
|
||||||
|
attribute.Int("gotenberg.pdf_engine.attempts", attempt+1),
|
||||||
|
)
|
||||||
|
span.SetStatus(codes.Ok, "")
|
||||||
|
return result.value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
joined = errors.Join(joined, result.err)
|
||||||
|
span.AddEvent("pdf_engine.attempt_failed", trace.WithAttributes(
|
||||||
|
attribute.String("engine", engineName(engine)),
|
||||||
|
))
|
||||||
|
case <-ctx.Done():
|
||||||
|
err := ctx.Err()
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := wrap(joined)
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// runWithFallbackVoid adapts [runWithFallback] for operations that return no
|
||||||
|
// value beyond an error.
|
||||||
|
func runWithFallbackVoid(
|
||||||
|
ctx context.Context,
|
||||||
|
spanName string,
|
||||||
|
engines []gotenberg.PdfEngine,
|
||||||
|
op func(ctx context.Context, engine gotenberg.PdfEngine) error,
|
||||||
|
wrap func(err error) error,
|
||||||
|
) error {
|
||||||
|
_, err := runWithFallback(ctx, spanName, engines,
|
||||||
|
func(ctx context.Context, engine gotenberg.PdfEngine) (struct{}, error) {
|
||||||
|
return struct{}{}, op(ctx, engine)
|
||||||
|
},
|
||||||
|
wrap,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Merge combines multiple PDF files into a single document using the first
|
// Merge combines multiple PDF files into a single document using the first
|
||||||
// available engine that supports PDF merging.
|
// available engine that supports PDF merging.
|
||||||
//
|
|
||||||
//nolint:dupl
|
|
||||||
func (multi *multiPdfEngines) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
func (multi *multiPdfEngines) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallbackVoid(ctx, "pdfengines.Merge", multi.mergeEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.Merge", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||||
defer span.End()
|
return engine.Merge(ctx, logger, inputPaths, outputPath)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error { return fmt.Errorf("merge PDFs with multi PDF engines: %w", err) },
|
||||||
errChan := make(chan error, 1)
|
)
|
||||||
|
|
||||||
for _, engine := range multi.mergeEngines {
|
|
||||||
go func(engine gotenberg.PdfEngine) {
|
|
||||||
errChan <- engine.Merge(ctx, logger, inputPaths, outputPath)
|
|
||||||
}(engine)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case mergeErr := <-errChan:
|
|
||||||
if mergeErr != nil {
|
|
||||||
err = errors.Join(err, mergeErr)
|
|
||||||
} else {
|
|
||||||
span.SetStatus(codes.Ok, "")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = fmt.Errorf("merge PDFs with multi PDF engines: %w", err)
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
type splitResult struct {
|
|
||||||
outputPaths []string
|
|
||||||
err error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split divides the PDF into separate pages using the first available engine
|
// Split divides the PDF into separate pages using the first available engine
|
||||||
// that supports PDF splitting.
|
// that supports PDF splitting.
|
||||||
func (multi *multiPdfEngines) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
func (multi *multiPdfEngines) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallback(ctx, "pdfengines.Split", multi.splitEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.Split", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) ([]string, error) {
|
||||||
defer span.End()
|
return engine.Split(ctx, logger, mode, inputPath, outputDirPath)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error { return fmt.Errorf("split PDF with multi PDF engines: %w", err) },
|
||||||
var mu sync.Mutex // to safely append errors.
|
)
|
||||||
|
|
||||||
for _, engine := range multi.splitEngines {
|
|
||||||
resultChan := make(chan splitResult, 1)
|
|
||||||
|
|
||||||
go func(engine gotenberg.PdfEngine) {
|
|
||||||
outputPaths, err := engine.Split(ctx, logger, mode, inputPath, outputDirPath)
|
|
||||||
resultChan <- splitResult{outputPaths: outputPaths, err: err}
|
|
||||||
}(engine)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case result := <-resultChan:
|
|
||||||
if result.err != nil {
|
|
||||||
mu.Lock()
|
|
||||||
err = errors.Join(err, result.err)
|
|
||||||
mu.Unlock()
|
|
||||||
} else {
|
|
||||||
span.SetStatus(codes.Ok, "")
|
|
||||||
return result.outputPaths, nil
|
|
||||||
}
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = fmt.Errorf("split PDF with multi PDF engines: %w", err)
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flatten merges existing annotation appearances with page content using the
|
// Flatten merges existing annotation appearances with page content using the
|
||||||
// first available engine that supports flattening.
|
// first available engine that supports flattening.
|
||||||
func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
|
func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallbackVoid(ctx, "pdfengines.Flatten", multi.flattenEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.Flatten", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||||
defer span.End()
|
return engine.Flatten(ctx, logger, inputPath)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error { return fmt.Errorf("flatten PDF with multi PDF engines: %w", err) },
|
||||||
errChan := make(chan error, 1)
|
)
|
||||||
|
|
||||||
for _, engine := range multi.flattenEngines {
|
|
||||||
go func(engine gotenberg.PdfEngine) {
|
|
||||||
errChan <- engine.Flatten(ctx, logger, inputPath)
|
|
||||||
}(engine)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case mergeErr := <-errChan:
|
|
||||||
if mergeErr != nil {
|
|
||||||
err = errors.Join(err, mergeErr)
|
|
||||||
} else {
|
|
||||||
span.SetStatus(codes.Ok, "")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = fmt.Errorf("flatten PDF with multi PDF engines: %w", err)
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert transforms the given PDF to a specific PDF format using the first
|
// Convert transforms the given PDF to a specific PDF format using the first
|
||||||
// available engine that supports PDF conversion.
|
// available engine that supports PDF conversion.
|
||||||
func (multi *multiPdfEngines) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
func (multi *multiPdfEngines) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallbackVoid(ctx, "pdfengines.Convert", multi.convertEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.Convert", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||||
defer span.End()
|
return engine.Convert(ctx, logger, formats, inputPath, outputPath)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error {
|
||||||
errChan := make(chan error, 1)
|
return fmt.Errorf("convert PDF to '%+v' with multi PDF engines: %w", formats, err)
|
||||||
|
},
|
||||||
for _, engine := range multi.convertEngines {
|
)
|
||||||
go func(engine gotenberg.PdfEngine) {
|
|
||||||
errChan <- engine.Convert(ctx, logger, formats, inputPath, outputPath)
|
|
||||||
}(engine)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case mergeErr := <-errChan:
|
|
||||||
if mergeErr != nil {
|
|
||||||
err = errors.Join(err, mergeErr)
|
|
||||||
} else {
|
|
||||||
span.SetStatus(codes.Ok, "")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = fmt.Errorf("convert PDF to '%+v' with multi PDF engines: %w", formats, err)
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
type readMetadataResult struct {
|
|
||||||
metadata map[string]any
|
|
||||||
err error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadMetadata extracts metadata from a PDF file using the first available
|
// ReadMetadata extracts metadata from a PDF file using the first available
|
||||||
// engine that supports metadata reading.
|
// engine that supports metadata reading.
|
||||||
//
|
|
||||||
//nolint:dupl
|
|
||||||
func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallback(ctx, "pdfengines.ReadMetadata", multi.readMetadataEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.ReadMetadata", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) (map[string]any, error) {
|
||||||
defer span.End()
|
return engine.ReadMetadata(ctx, logger, inputPath)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error { return fmt.Errorf("read PDF metadata with multi PDF engines: %w", err) },
|
||||||
var mu sync.Mutex // to safely append errors.
|
)
|
||||||
|
|
||||||
for _, engine := range multi.readMetadataEngines {
|
|
||||||
resultChan := make(chan readMetadataResult, 1)
|
|
||||||
|
|
||||||
go func(engine gotenberg.PdfEngine) {
|
|
||||||
metadata, err := engine.ReadMetadata(ctx, logger, inputPath)
|
|
||||||
resultChan <- readMetadataResult{metadata: metadata, err: err}
|
|
||||||
}(engine)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case result := <-resultChan:
|
|
||||||
if result.err != nil {
|
|
||||||
mu.Lock()
|
|
||||||
err = errors.Join(err, result.err)
|
|
||||||
mu.Unlock()
|
|
||||||
} else {
|
|
||||||
span.SetStatus(codes.Ok, "")
|
|
||||||
return result.metadata, nil
|
|
||||||
}
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = fmt.Errorf("read PDF metadata with multi PDF engines: %w", err)
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteMetadata embeds metadata into a PDF file using the first available
|
// WriteMetadata embeds metadata into a PDF file using the first available
|
||||||
// engine that supports metadata writing.
|
// engine that supports metadata writing.
|
||||||
func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
|
func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallbackVoid(ctx, "pdfengines.WriteMetadata", multi.writeMetadataEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.WriteMetadata", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||||
defer span.End()
|
return engine.WriteMetadata(ctx, logger, metadata, inputPath)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error { return fmt.Errorf("write PDF metadata with multi PDF engines: %w", err) },
|
||||||
errChan := make(chan error, 1)
|
)
|
||||||
|
|
||||||
for _, engine := range multi.writeMetadataEngines {
|
|
||||||
go func(engine gotenberg.PdfEngine) {
|
|
||||||
errChan <- engine.WriteMetadata(ctx, logger, metadata, inputPath)
|
|
||||||
}(engine)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case writeMetadataErr := <-errChan:
|
|
||||||
if writeMetadataErr != nil {
|
|
||||||
err = errors.Join(err, writeMetadataErr)
|
|
||||||
} else {
|
|
||||||
span.SetStatus(codes.Ok, "")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = fmt.Errorf("write PDF metadata with multi PDF engines: %w", err)
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
type pageCountResult struct {
|
|
||||||
pageCount int
|
|
||||||
err error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PageCount returns the number of pages in a PDF file using the first available
|
// PageCount returns the number of pages in a PDF file using the first available
|
||||||
// engine that supports metadata reading.
|
// engine that supports metadata reading.
|
||||||
func (multi *multiPdfEngines) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
|
func (multi *multiPdfEngines) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallback(ctx, "pdfengines.PageCount", multi.readMetadataEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.PageCount", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) (int, error) {
|
||||||
defer span.End()
|
return engine.PageCount(ctx, logger, inputPath)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error { return fmt.Errorf("page count with multi PDF engines: %w", err) },
|
||||||
var mu sync.Mutex // to safely append errors.
|
)
|
||||||
|
|
||||||
for _, engine := range multi.readMetadataEngines {
|
|
||||||
resultChan := make(chan pageCountResult, 1)
|
|
||||||
|
|
||||||
go func(engine gotenberg.PdfEngine) {
|
|
||||||
pageCount, err := engine.PageCount(ctx, logger, inputPath)
|
|
||||||
resultChan <- pageCountResult{pageCount: pageCount, err: err}
|
|
||||||
}(engine)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case result := <-resultChan:
|
|
||||||
if result.err != nil {
|
|
||||||
mu.Lock()
|
|
||||||
err = errors.Join(err, result.err)
|
|
||||||
mu.Unlock()
|
|
||||||
} else {
|
|
||||||
span.SetStatus(codes.Ok, "")
|
|
||||||
return result.pageCount, nil
|
|
||||||
}
|
|
||||||
case <-ctx.Done():
|
|
||||||
return 0, ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = fmt.Errorf("page count with multi PDF engines: %w", err)
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
type readBookmarksResult struct {
|
|
||||||
bookmarks []gotenberg.Bookmark
|
|
||||||
err error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadBookmarks reads bookmarks from a PDF file using the first available
|
// ReadBookmarks reads bookmarks from a PDF file using the first available
|
||||||
// engine that supports bookmarks reading.
|
// engine that supports bookmarks reading.
|
||||||
//
|
|
||||||
//nolint:dupl
|
|
||||||
func (multi *multiPdfEngines) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
|
func (multi *multiPdfEngines) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallback(ctx, "pdfengines.ReadBookmarks", multi.readBookmarksEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.ReadBookmarks", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) ([]gotenberg.Bookmark, error) {
|
||||||
defer span.End()
|
return engine.ReadBookmarks(ctx, logger, inputPath)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error { return fmt.Errorf("read PDF bookmarks with multi PDF engines: %w", err) },
|
||||||
var mu sync.Mutex // to safely append errors.
|
)
|
||||||
|
|
||||||
for _, engine := range multi.readBookmarksEngines {
|
|
||||||
resultChan := make(chan readBookmarksResult, 1)
|
|
||||||
|
|
||||||
go func(engine gotenberg.PdfEngine) {
|
|
||||||
bookmarks, err := engine.ReadBookmarks(ctx, logger, inputPath)
|
|
||||||
resultChan <- readBookmarksResult{bookmarks: bookmarks, err: err}
|
|
||||||
}(engine)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case result := <-resultChan:
|
|
||||||
if result.err != nil {
|
|
||||||
mu.Lock()
|
|
||||||
err = errors.Join(err, result.err)
|
|
||||||
mu.Unlock()
|
|
||||||
} else {
|
|
||||||
span.SetStatus(codes.Ok, "")
|
|
||||||
return result.bookmarks, nil
|
|
||||||
}
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = fmt.Errorf("read PDF bookmarks with multi PDF engines: %w", err)
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteBookmarks adds a document outline (bookmarks) to a PDF file using the
|
// WriteBookmarks adds a document outline (bookmarks) to a PDF file using the
|
||||||
// first available engine that supports bookmarks writing.
|
// first available engine that supports bookmarks writing.
|
||||||
func (multi *multiPdfEngines) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
|
func (multi *multiPdfEngines) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallbackVoid(ctx, "pdfengines.WriteBookmarks", multi.writeBookmarksEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.WriteBookmarks", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||||
defer span.End()
|
return engine.WriteBookmarks(ctx, logger, inputPath, bookmarks)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error { return fmt.Errorf("write PDF bookmarks with multi PDF engines: %w", err) },
|
||||||
errChan := make(chan error, 1)
|
)
|
||||||
|
|
||||||
for _, engine := range multi.writeBookmarksEngines {
|
|
||||||
go func(engine gotenberg.PdfEngine) {
|
|
||||||
errChan <- engine.WriteBookmarks(ctx, logger, inputPath, bookmarks)
|
|
||||||
}(engine)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case writeBookmarksErr := <-errChan:
|
|
||||||
if writeBookmarksErr != nil {
|
|
||||||
err = errors.Join(err, writeBookmarksErr)
|
|
||||||
} else {
|
|
||||||
span.SetStatus(codes.Ok, "")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = fmt.Errorf("write PDF bookmarks with multi PDF engines: %w", err)
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt adds password protection to a PDF file using the first available
|
// Encrypt adds password protection to a PDF file using the first available
|
||||||
// engine that supports password protection.
|
// engine that supports password protection.
|
||||||
func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
|
func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallbackVoid(ctx, "pdfengines.Encrypt", multi.passwordEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.Encrypt", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||||
defer span.End()
|
return engine.Encrypt(ctx, logger, inputPath, opts)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error { return fmt.Errorf("encrypt PDF using multi PDF engines: %w", err) },
|
||||||
errChan := make(chan error, 1)
|
)
|
||||||
|
|
||||||
for _, engine := range multi.passwordEngines {
|
|
||||||
go func(engine gotenberg.PdfEngine) {
|
|
||||||
errChan <- engine.Encrypt(ctx, logger, inputPath, userPassword, ownerPassword)
|
|
||||||
}(engine)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case protectErr := <-errChan:
|
|
||||||
if protectErr != nil {
|
|
||||||
err = errors.Join(err, protectErr)
|
|
||||||
} else {
|
|
||||||
span.SetStatus(codes.Ok, "")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = fmt.Errorf("encrypt PDF using multi PDF engines: %w", err)
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmbedFiles embeds files into a PDF using the first available
|
// EmbedFiles embeds files into a PDF using the first available engine that
|
||||||
// engine that supports file embedding.
|
// supports file embedding.
|
||||||
//
|
|
||||||
//nolint:dupl
|
|
||||||
func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallbackVoid(ctx, "pdfengines.EmbedFiles", multi.embedEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.EmbedFiles", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||||
defer span.End()
|
return engine.EmbedFiles(ctx, logger, filePaths, inputPath)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error { return fmt.Errorf("embed files into PDF using multi PDF engines: %w", err) },
|
||||||
errChan := make(chan error, 1)
|
)
|
||||||
|
|
||||||
for _, engine := range multi.embedEngines {
|
|
||||||
go func(engine gotenberg.PdfEngine) {
|
|
||||||
errChan <- engine.EmbedFiles(ctx, logger, filePaths, inputPath)
|
|
||||||
}(engine)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case embedErr := <-errChan:
|
|
||||||
if embedErr != nil {
|
|
||||||
err = errors.Join(err, embedErr)
|
|
||||||
} else {
|
|
||||||
span.SetStatus(codes.Ok, "")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = fmt.Errorf("embed files into PDF using multi PDF engines: %w", err)
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watermark applies a watermark (behind page content) to a PDF file using the
|
// Watermark applies a watermark (behind page content) to a PDF file using the
|
||||||
// first available engine that supports watermarking.
|
// first available engine that supports watermarking.
|
||||||
//
|
|
||||||
//nolint:dupl
|
|
||||||
func (multi *multiPdfEngines) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
func (multi *multiPdfEngines) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallbackVoid(ctx, "pdfengines.Watermark", multi.watermarkEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.Watermark", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||||
defer span.End()
|
return engine.Watermark(ctx, logger, inputPath, stamp)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error { return fmt.Errorf("watermark PDF with multi PDF engines: %w", err) },
|
||||||
errChan := make(chan error, 1)
|
)
|
||||||
|
|
||||||
for _, engine := range multi.watermarkEngines {
|
|
||||||
go func(engine gotenberg.PdfEngine) {
|
|
||||||
errChan <- engine.Watermark(ctx, logger, inputPath, stamp)
|
|
||||||
}(engine)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case watermarkErr := <-errChan:
|
|
||||||
if watermarkErr != nil {
|
|
||||||
err = errors.Join(err, watermarkErr)
|
|
||||||
} else {
|
|
||||||
span.SetStatus(codes.Ok, "")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = fmt.Errorf("watermark PDF with multi PDF engines: %w", err)
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stamp applies a stamp (on top of page content) to a PDF file using the
|
// Stamp applies a stamp (on top of page content) to a PDF file using the first
|
||||||
// first available engine that supports stamping.
|
// available engine that supports stamping.
|
||||||
//
|
|
||||||
//nolint:dupl
|
|
||||||
func (multi *multiPdfEngines) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
func (multi *multiPdfEngines) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallbackVoid(ctx, "pdfengines.Stamp", multi.stampEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.Stamp", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||||
defer span.End()
|
return engine.Stamp(ctx, logger, inputPath, stamp)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error { return fmt.Errorf("stamp PDF with multi PDF engines: %w", err) },
|
||||||
errChan := make(chan error, 1)
|
)
|
||||||
|
|
||||||
for _, engine := range multi.stampEngines {
|
|
||||||
go func(engine gotenberg.PdfEngine) {
|
|
||||||
errChan <- engine.Stamp(ctx, logger, inputPath, stamp)
|
|
||||||
}(engine)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case stampErr := <-errChan:
|
|
||||||
if stampErr != nil {
|
|
||||||
err = errors.Join(err, stampErr)
|
|
||||||
} else {
|
|
||||||
span.SetStatus(codes.Ok, "")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = fmt.Errorf("stamp PDF with multi PDF engines: %w", err)
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rotate rotates pages of a PDF file using the first available engine that
|
// Rotate rotates pages of a PDF file using the first available engine that
|
||||||
// supports rotation.
|
// supports rotation.
|
||||||
func (multi *multiPdfEngines) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
|
func (multi *multiPdfEngines) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallbackVoid(ctx, "pdfengines.Rotate", multi.rotateEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.Rotate", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||||
defer span.End()
|
return engine.Rotate(ctx, logger, inputPath, angle, pages)
|
||||||
|
},
|
||||||
var err error
|
func(err error) error { return fmt.Errorf("rotate PDF with multi PDF engines: %w", err) },
|
||||||
errChan := make(chan error, 1)
|
)
|
||||||
|
|
||||||
for _, engine := range multi.rotateEngines {
|
|
||||||
go func(engine gotenberg.PdfEngine) {
|
|
||||||
errChan <- engine.Rotate(ctx, logger, inputPath, angle, pages)
|
|
||||||
}(engine)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case rotateErr := <-errChan:
|
|
||||||
if rotateErr != nil {
|
|
||||||
err = errors.Join(err, rotateErr)
|
|
||||||
} else {
|
|
||||||
span.SetStatus(codes.Ok, "")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = fmt.Errorf("rotate PDF with multi PDF engines: %w", err)
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmbedFilesMetadata sets metadata on embedded files using the first available
|
// EmbedFilesMetadata sets metadata on embedded files using the first available
|
||||||
// engine that supports it.
|
// engine that supports it.
|
||||||
//
|
|
||||||
//nolint:dupl
|
|
||||||
func (multi *multiPdfEngines) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
|
func (multi *multiPdfEngines) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
|
||||||
tracer := gotenberg.Tracer()
|
return runWithFallbackVoid(ctx, "pdfengines.EmbedFilesMetadata", multi.embedMetadataEngines,
|
||||||
ctx, span := tracer.Start(ctx, "pdfengines.EmbedFilesMetadata", trace.WithSpanKind(trace.SpanKindInternal))
|
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||||
defer span.End()
|
return engine.EmbedFilesMetadata(ctx, logger, metadata, inputPath)
|
||||||
|
},
|
||||||
|
func(err error) error { return fmt.Errorf("set embeds metadata using multi PDF engines: %w", err) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
var err error
|
// InjectFacturXXMP injects Factur-X/ZUGFeRD XMP metadata using the first
|
||||||
errChan := make(chan error, 1)
|
// available engine that supports it.
|
||||||
|
func (multi *multiPdfEngines) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error {
|
||||||
|
return runWithFallbackVoid(ctx, "pdfengines.InjectFacturXXMP", multi.facturXEngines,
|
||||||
|
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||||
|
return engine.InjectFacturXXMP(ctx, logger, facturX, inputPath)
|
||||||
|
},
|
||||||
|
func(err error) error { return fmt.Errorf("inject Factur-X XMP with multi PDF engines: %w", err) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
for _, engine := range multi.embedMetadataEngines {
|
// ReadPdfAConformance reads the PDF/A part and conformance using the first
|
||||||
go func(engine gotenberg.PdfEngine) {
|
// available engine that supports it.
|
||||||
errChan <- engine.EmbedFilesMetadata(ctx, logger, metadata, inputPath)
|
func (multi *multiPdfEngines) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
|
||||||
}(engine)
|
type pdfaConf struct {
|
||||||
|
part string
|
||||||
select {
|
conformance string
|
||||||
case setErr := <-errChan:
|
|
||||||
if setErr != nil {
|
|
||||||
err = errors.Join(err, setErr)
|
|
||||||
} else {
|
|
||||||
span.SetStatus(codes.Ok, "")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
result, err := runWithFallback(ctx, "pdfengines.ReadPdfAConformance", multi.facturXEngines,
|
||||||
err = fmt.Errorf("set embeds metadata using multi PDF engines: %w", err)
|
func(ctx context.Context, engine gotenberg.PdfEngine) (pdfaConf, error) {
|
||||||
span.RecordError(err)
|
part, conformance, err := engine.ReadPdfAConformance(ctx, logger, inputPath)
|
||||||
span.SetStatus(codes.Error, err.Error())
|
return pdfaConf{part: part, conformance: conformance}, err
|
||||||
|
},
|
||||||
return err
|
func(err error) error { return fmt.Errorf("read PDF/A conformance with multi PDF engines: %w", err) },
|
||||||
|
)
|
||||||
|
return result.part, result.conformance, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interface guards.
|
// Interface guards.
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ type PdfEngines struct {
|
|||||||
watermarkNames []string
|
watermarkNames []string
|
||||||
stampNames []string
|
stampNames []string
|
||||||
rotateNames []string
|
rotateNames []string
|
||||||
|
facturXNames []string
|
||||||
engines []gotenberg.PdfEngine
|
engines []gotenberg.PdfEngine
|
||||||
disableRoutes bool
|
disableRoutes bool
|
||||||
}
|
}
|
||||||
@@ -66,6 +67,7 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
fs.StringSlice("pdfengines-watermark-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the watermark feature - empty means all")
|
fs.StringSlice("pdfengines-watermark-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the watermark feature - empty means all")
|
||||||
fs.StringSlice("pdfengines-stamp-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the stamp feature - empty means all")
|
fs.StringSlice("pdfengines-stamp-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the stamp feature - empty means all")
|
||||||
fs.StringSlice("pdfengines-rotate-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the rotate feature - empty means all")
|
fs.StringSlice("pdfengines-rotate-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the rotate feature - empty means all")
|
||||||
|
fs.StringSlice("pdfengines-factur-x-engines", []string{"qpdf"}, "Set the PDF engines and their order for the Factur-X XMP feature - empty means all")
|
||||||
fs.Bool("pdfengines-disable-routes", false, "Disable the routes")
|
fs.Bool("pdfengines-disable-routes", false, "Disable the routes")
|
||||||
|
|
||||||
// Deprecated flags.
|
// Deprecated flags.
|
||||||
@@ -99,6 +101,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
|
|||||||
watermarkNames := flags.MustStringSlice("pdfengines-watermark-engines")
|
watermarkNames := flags.MustStringSlice("pdfengines-watermark-engines")
|
||||||
stampNames := flags.MustStringSlice("pdfengines-stamp-engines")
|
stampNames := flags.MustStringSlice("pdfengines-stamp-engines")
|
||||||
rotateNames := flags.MustStringSlice("pdfengines-rotate-engines")
|
rotateNames := flags.MustStringSlice("pdfengines-rotate-engines")
|
||||||
|
facturXNames := flags.MustStringSlice("pdfengines-factur-x-engines")
|
||||||
mod.disableRoutes = flags.MustBool("pdfengines-disable-routes")
|
mod.disableRoutes = flags.MustBool("pdfengines-disable-routes")
|
||||||
|
|
||||||
engines, err := ctx.Modules(new(gotenberg.PdfEngine))
|
engines, err := ctx.Modules(new(gotenberg.PdfEngine))
|
||||||
@@ -195,6 +198,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
|
|||||||
mod.rotateNames = rotateNames
|
mod.rotateNames = rotateNames
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod.facturXNames = defaultNames
|
||||||
|
if len(facturXNames) > 0 {
|
||||||
|
mod.facturXNames = facturXNames
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +211,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
|
|||||||
// actually exist.
|
// actually exist.
|
||||||
func (mod *PdfEngines) Validate() error {
|
func (mod *PdfEngines) Validate() error {
|
||||||
if len(mod.engines) == 0 {
|
if len(mod.engines) == 0 {
|
||||||
return errors.New("no PDF engine")
|
return errors.New("no PDF engine is available; enable at least one engine module (e.g. qpdf, pdfcpu, pdftk, libreoffice-pdfengine, exiftool)")
|
||||||
}
|
}
|
||||||
|
|
||||||
availableEngines := make([]string, len(mod.engines))
|
availableEngines := make([]string, len(mod.engines))
|
||||||
@@ -250,6 +258,7 @@ func (mod *PdfEngines) Validate() error {
|
|||||||
findNonExistingEngines(mod.watermarkNames)
|
findNonExistingEngines(mod.watermarkNames)
|
||||||
findNonExistingEngines(mod.stampNames)
|
findNonExistingEngines(mod.stampNames)
|
||||||
findNonExistingEngines(mod.rotateNames)
|
findNonExistingEngines(mod.rotateNames)
|
||||||
|
findNonExistingEngines(mod.facturXNames)
|
||||||
|
|
||||||
if len(nonExistingEngines) == 0 {
|
if len(nonExistingEngines) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -276,6 +285,7 @@ func (mod *PdfEngines) SystemMessages() []string {
|
|||||||
fmt.Sprintf("watermark engines - %s", strings.Join(mod.watermarkNames, " ")),
|
fmt.Sprintf("watermark engines - %s", strings.Join(mod.watermarkNames, " ")),
|
||||||
fmt.Sprintf("stamp engines - %s", strings.Join(mod.stampNames, " ")),
|
fmt.Sprintf("stamp engines - %s", strings.Join(mod.stampNames, " ")),
|
||||||
fmt.Sprintf("rotate engines - %s", strings.Join(mod.rotateNames, " ")),
|
fmt.Sprintf("rotate engines - %s", strings.Join(mod.rotateNames, " ")),
|
||||||
|
fmt.Sprintf("factur-x engines - %s", strings.Join(mod.facturXNames, " ")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +320,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
|
|||||||
engines(mod.watermarkNames),
|
engines(mod.watermarkNames),
|
||||||
engines(mod.stampNames),
|
engines(mod.stampNames),
|
||||||
engines(mod.rotateNames),
|
engines(mod.rotateNames),
|
||||||
|
engines(mod.facturXNames),
|
||||||
), nil
|
), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +351,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
|
|||||||
watermarkRoute(engine),
|
watermarkRoute(engine),
|
||||||
stampRoute(engine),
|
stampRoute(engine),
|
||||||
rotateRoute(engine),
|
rotateRoute(engine),
|
||||||
|
facturXRoute(engine),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -467,21 +467,244 @@ func EmbedFilesMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metada
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormDataPdfEncrypt extracts encryption parameters from form data.
|
// FormDataPdfFacturX extracts the Factur-X parameters and the invoice XML path
|
||||||
func FormDataPdfEncrypt(form *api.FormData) (userPassword, ownerPassword string) {
|
// from form data. Factur-X is requested when both facturxConformanceLevel and
|
||||||
form.String("userPassword", &userPassword, "")
|
// facturxXml are provided. The embedded XML always takes the canonical
|
||||||
form.String("ownerPassword", &ownerPassword, "")
|
// [gotenberg.FacturXDocumentFileName] name.
|
||||||
return userPassword, ownerPassword
|
func FormDataPdfFacturX(form *api.FormData) (gotenberg.FacturX, string) {
|
||||||
|
var (
|
||||||
|
facturxXmlPath string
|
||||||
|
conformanceLevel string
|
||||||
|
documentType string
|
||||||
|
version string
|
||||||
|
)
|
||||||
|
|
||||||
|
form.
|
||||||
|
FacturXXml(&facturxXmlPath).
|
||||||
|
Custom("facturxConformanceLevel", func(value string) error {
|
||||||
|
conformanceLevel = value
|
||||||
|
switch value {
|
||||||
|
case "",
|
||||||
|
gotenberg.FacturXConformanceMinimum,
|
||||||
|
gotenberg.FacturXConformanceBasicWL,
|
||||||
|
gotenberg.FacturXConformanceBasic,
|
||||||
|
gotenberg.FacturXConformanceEN16931,
|
||||||
|
gotenberg.FacturXConformanceExtended,
|
||||||
|
gotenberg.FacturXConformanceXRechnung:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported conformance level '%s'", value)
|
||||||
|
}
|
||||||
|
}).
|
||||||
|
Custom("facturxDocumentType", func(value string) error {
|
||||||
|
if value == "" {
|
||||||
|
documentType = gotenberg.FacturXDocumentTypeInvoice
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
documentType = value
|
||||||
|
switch value {
|
||||||
|
case gotenberg.FacturXDocumentTypeInvoice,
|
||||||
|
gotenberg.FacturXDocumentTypeOrder,
|
||||||
|
gotenberg.FacturXDocumentTypeOrderResponse,
|
||||||
|
gotenberg.FacturXDocumentTypeOrderChange:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported document type '%s'", value)
|
||||||
|
}
|
||||||
|
}).
|
||||||
|
String("facturxVersion", &version, "1.0")
|
||||||
|
|
||||||
|
return gotenberg.FacturX{
|
||||||
|
ConformanceLevel: conformanceLevel,
|
||||||
|
DocumentType: documentType,
|
||||||
|
DocumentFileName: gotenberg.FacturXDocumentFileName,
|
||||||
|
Version: version,
|
||||||
|
}, facturxXmlPath
|
||||||
}
|
}
|
||||||
|
|
||||||
// EncryptPdfStub adds password protection to PDF files.
|
// isPdfA3 reports whether the format is a PDF/A-3 variant, the only family that
|
||||||
func EncryptPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, userPassword, ownerPassword string, inputPaths []string) error {
|
// allows the embedded files Factur-X requires.
|
||||||
if userPassword == "" {
|
func isPdfA3(pdfA string) bool {
|
||||||
|
return pdfA == gotenberg.PdfA3a || pdfA == gotenberg.PdfA3b || pdfA == gotenberg.PdfA3u
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateFacturXCompat enforces the Factur-X pairing and PDF/A-3 rules. It
|
||||||
|
// returns a 400 error when the request is half-specified, or when an explicit
|
||||||
|
// PDF/A format is not a PDF/A-3 variant.
|
||||||
|
func ValidateFacturXCompat(facturX gotenberg.FacturX, facturxXmlPath string, pdfFormats gotenberg.PdfFormats) error {
|
||||||
|
if facturX.ConformanceLevel == "" && facturxXmlPath == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if facturX.ConformanceLevel == "" {
|
||||||
|
return api.WrapError(
|
||||||
|
errors.New("facturxConformanceLevel is required when facturxXml is provided"),
|
||||||
|
api.NewSentinelHttpError(http.StatusBadRequest, "Invalid form data: 'facturxConformanceLevel' is required when 'facturxXml' is provided"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if facturxXmlPath == "" {
|
||||||
|
return api.WrapError(
|
||||||
|
errors.New("facturxXml is required when facturxConformanceLevel is set"),
|
||||||
|
api.NewSentinelHttpError(http.StatusBadRequest, "Invalid form data: 'facturxXml' file is required when 'facturxConformanceLevel' is set"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pdfFormats.PdfA != "" && !isPdfA3(pdfFormats.PdfA) {
|
||||||
|
return api.WrapError(
|
||||||
|
fmt.Errorf("Factur-X requires PDF/A-3, got '%s'", pdfFormats.PdfA),
|
||||||
|
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid form data: Factur-X requires a PDF/A-3 variant (PDF/A-3a, PDF/A-3b, or PDF/A-3u), got '%s'", pdfFormats.PdfA)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FacturXPdfFormats returns the PDF/A formats to convert to so the output meets
|
||||||
|
// Factur-X's PDF/A-3 requirement. It returns pdfFormats unchanged when Factur-X
|
||||||
|
// is not requested or the caller already asked for a PDF/A-3 variant. Otherwise
|
||||||
|
// it defaults to PDF/A-3b, except for pre-existing PDFs (sourceDoc false) that
|
||||||
|
// already carry PDF/A-3, which are left untouched.
|
||||||
|
func FacturXPdfFormats(ctx *api.Context, engine gotenberg.PdfEngine, facturX gotenberg.FacturX, pdfFormats gotenberg.PdfFormats, sourceDoc bool, inputPaths []string) gotenberg.PdfFormats {
|
||||||
|
if facturX.ConformanceLevel == "" || isPdfA3(pdfFormats.PdfA) {
|
||||||
|
return pdfFormats
|
||||||
|
}
|
||||||
|
|
||||||
|
if sourceDoc {
|
||||||
|
pdfFormats.PdfA = gotenberg.PdfA3b
|
||||||
|
return pdfFormats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-existing PDFs: keep an already-PDF/A-3 input as-is, otherwise default
|
||||||
|
// to PDF/A-3b.
|
||||||
|
for _, inputPath := range inputPaths {
|
||||||
|
part, _, err := engine.ReadPdfAConformance(ctx, ctx.Log(), inputPath)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Log().DebugContext(ctx, fmt.Sprintf("read PDF/A conformance of '%s', assuming not PDF/A-3: %s", inputPath, err))
|
||||||
|
part = ""
|
||||||
|
}
|
||||||
|
if part != "3" {
|
||||||
|
pdfFormats.PdfA = gotenberg.PdfA3b
|
||||||
|
return pdfFormats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pdfFormats
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyFacturXStub turns each input PDF into a Factur-X document: it embeds the
|
||||||
|
// CII invoice XML under the canonical name with AFRelationship "Alternative",
|
||||||
|
// then injects the fx XMP metadata. The inputs must already be PDF/A-3 (see
|
||||||
|
// [FacturXPdfFormats]). It is a no-op when Factur-X is not requested.
|
||||||
|
func ApplyFacturXStub(ctx *api.Context, engine gotenberg.PdfEngine, facturX gotenberg.FacturX, facturxXmlPath string, inputPaths []string) error {
|
||||||
|
if facturX.ConformanceLevel == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := embedFacturXXml(ctx, engine, facturxXmlPath, inputPaths)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := map[string]map[string]string{
|
||||||
|
facturX.DocumentFileName: {
|
||||||
|
"mimeType": "text/xml",
|
||||||
|
"relationship": "Alternative",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err = EmbedFilesMetadataStub(ctx, engine, metadata, inputPaths)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set Factur-X embed metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = InjectFacturXXMPStub(ctx, engine, facturX, inputPaths)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// embedFacturXXml embeds the Factur-X invoice XML into each PDF under the
|
||||||
|
// canonical [gotenberg.FacturXDocumentFileName] name, regardless of the
|
||||||
|
// uploaded file name.
|
||||||
|
func embedFacturXXml(ctx *api.Context, engine gotenberg.PdfEngine, facturxXmlPath string, inputPaths []string) error {
|
||||||
|
embedDir, err := ctx.CreateSubDirectory(uuid.New().String())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create Factur-X embed subdirectory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
canonicalPath := fmt.Sprintf("%s/%s", embedDir, gotenberg.FacturXDocumentFileName)
|
||||||
|
err = os.Symlink(facturxXmlPath, canonicalPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("symlink Factur-X invoice XML: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, inputPath := range inputPaths {
|
||||||
|
err = engine.EmbedFiles(ctx, ctx.Log(), []string{canonicalPath}, inputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("embed Factur-X invoice XML into PDF '%s': %w", inputPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InjectFacturXXMPStub injects Factur-X XMP metadata into PDF files. If the
|
||||||
|
// Factur-X data is not set, it does nothing.
|
||||||
|
func InjectFacturXXMPStub(ctx *api.Context, engine gotenberg.PdfEngine, facturX gotenberg.FacturX, inputPaths []string) error {
|
||||||
|
if facturX.ConformanceLevel == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, inputPath := range inputPaths {
|
for _, inputPath := range inputPaths {
|
||||||
err := engine.Encrypt(ctx, ctx.Log(), inputPath, userPassword, ownerPassword)
|
err := engine.InjectFacturXXMP(ctx, ctx.Log(), facturX, inputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inject Factur-X XMP into PDF '%s': %w", inputPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormDataPdfEncrypt extracts the encryption parameters and permissions from
|
||||||
|
// form data. Permissions default to allowed.
|
||||||
|
func FormDataPdfEncrypt(form *api.FormData) gotenberg.EncryptOptions {
|
||||||
|
var opts gotenberg.EncryptOptions
|
||||||
|
form.
|
||||||
|
String("userPassword", &opts.UserPassword, "").
|
||||||
|
String("ownerPassword", &opts.OwnerPassword, "").
|
||||||
|
Bool("allowPrinting", &opts.Permissions.AllowPrinting, true).
|
||||||
|
Bool("allowCopying", &opts.Permissions.AllowCopying, true).
|
||||||
|
Bool("allowModifying", &opts.Permissions.AllowModifying, true).
|
||||||
|
Bool("allowAnnotating", &opts.Permissions.AllowAnnotating, true).
|
||||||
|
Bool("allowFillingForms", &opts.Permissions.AllowFillingForms, true).
|
||||||
|
Bool("allowAssembling", &opts.Permissions.AllowAssembling, true)
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatePdfEncryptCompat returns a 400 error when permission restrictions are
|
||||||
|
// requested without a password to anchor them.
|
||||||
|
func ValidatePdfEncryptCompat(opts gotenberg.EncryptOptions) error {
|
||||||
|
if opts.Permissions.Restricted() && opts.UserPassword == "" && opts.OwnerPassword == "" {
|
||||||
|
return api.WrapError(
|
||||||
|
errors.New("permission restrictions require a password"),
|
||||||
|
api.NewSentinelHttpError(http.StatusBadRequest, "Invalid form data: permission restrictions require a 'userPassword' or 'ownerPassword'"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptPdfStub adds password protection and permission restrictions to PDF
|
||||||
|
// files. It does nothing when no password is provided.
|
||||||
|
func EncryptPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, opts gotenberg.EncryptOptions, inputPaths []string) error {
|
||||||
|
if opts.UserPassword == "" && opts.OwnerPassword == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, inputPath := range inputPaths {
|
||||||
|
err := engine.Encrypt(ctx, ctx.Log(), inputPath, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encrypt PDF '%s': %w", inputPath, err)
|
return fmt.Errorf("encrypt PDF '%s': %w", inputPath, err)
|
||||||
}
|
}
|
||||||
@@ -699,7 +922,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
pdfFormats := FormDataPdfFormats(form)
|
pdfFormats := FormDataPdfFormats(form)
|
||||||
metadata := FormDataPdfMetadata(form, false)
|
metadata := FormDataPdfMetadata(form, false)
|
||||||
bookmarks := FormDataPdfBookmarks(form, false)
|
bookmarks := FormDataPdfBookmarks(form, false)
|
||||||
userPassword, ownerPassword := FormDataPdfEncrypt(form)
|
encrypt := FormDataPdfEncrypt(form)
|
||||||
embedPaths := FormDataPdfEmbeds(form)
|
embedPaths := FormDataPdfEmbeds(form)
|
||||||
watermark := FormDataPdfWatermark(form, false)
|
watermark := FormDataPdfWatermark(form, false)
|
||||||
watermarkFile := FormDataPdfWatermarkFile(form)
|
watermarkFile := FormDataPdfWatermarkFile(form)
|
||||||
@@ -707,6 +930,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
stampFile := FormDataPdfStampFile(form)
|
stampFile := FormDataPdfStampFile(form)
|
||||||
angle, rotatePages := FormDataPdfRotate(form, false)
|
angle, rotatePages := FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
||||||
|
facturX, facturxXmlPath := FormDataPdfFacturX(form)
|
||||||
|
|
||||||
var inputPaths []string
|
var inputPaths []string
|
||||||
var flatten bool
|
var flatten bool
|
||||||
@@ -729,7 +953,17 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("validate stamp: %w", err)
|
return fmt.Errorf("validate stamp: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
|
err = ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ValidatePdfEncryptCompat(encrypt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ValidateFacturXCompat(facturX, facturxXmlPath, pdfFormats)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -764,6 +998,8 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pdfFormats = FacturXPdfFormats(ctx, engine, facturX, pdfFormats, false, outputPaths)
|
||||||
|
|
||||||
outputPaths, err = ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
outputPaths, err = ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert PDF: %w", err)
|
return fmt.Errorf("convert PDF: %w", err)
|
||||||
@@ -830,7 +1066,12 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("set embeds metadata: %w", err)
|
return fmt.Errorf("set embeds metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
|
err = ApplyFacturXStub(ctx, engine, facturX, facturxXmlPath, outputPaths)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("apply Factur-X: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = EncryptPdfStub(ctx, engine, encrypt, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||||
}
|
}
|
||||||
@@ -858,7 +1099,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
mode := FormDataPdfSplitMode(form, true)
|
mode := FormDataPdfSplitMode(form, true)
|
||||||
pdfFormats := FormDataPdfFormats(form)
|
pdfFormats := FormDataPdfFormats(form)
|
||||||
metadata := FormDataPdfMetadata(form, false)
|
metadata := FormDataPdfMetadata(form, false)
|
||||||
userPassword, ownerPassword := FormDataPdfEncrypt(form)
|
encrypt := FormDataPdfEncrypt(form)
|
||||||
embedPaths := FormDataPdfEmbeds(form)
|
embedPaths := FormDataPdfEmbeds(form)
|
||||||
watermark := FormDataPdfWatermark(form, false)
|
watermark := FormDataPdfWatermark(form, false)
|
||||||
watermarkFile := FormDataPdfWatermarkFile(form)
|
watermarkFile := FormDataPdfWatermarkFile(form)
|
||||||
@@ -866,6 +1107,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
stampFile := FormDataPdfStampFile(form)
|
stampFile := FormDataPdfStampFile(form)
|
||||||
angle, rotatePages := FormDataPdfRotate(form, false)
|
angle, rotatePages := FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
||||||
|
facturX, facturxXmlPath := FormDataPdfFacturX(form)
|
||||||
|
|
||||||
var inputPaths []string
|
var inputPaths []string
|
||||||
var flatten bool
|
var flatten bool
|
||||||
@@ -886,7 +1128,17 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("validate stamp: %w", err)
|
return fmt.Errorf("validate stamp: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
|
err = ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ValidatePdfEncryptCompat(encrypt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ValidateFacturXCompat(facturX, facturxXmlPath, pdfFormats)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -918,6 +1170,8 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pdfFormats = FacturXPdfFormats(ctx, engine, facturX, pdfFormats, false, outputPaths)
|
||||||
|
|
||||||
convertOutputPaths, err := ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
convertOutputPaths, err := ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert PDFs: %w", err)
|
return fmt.Errorf("convert PDFs: %w", err)
|
||||||
@@ -940,7 +1194,12 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("set embeds metadata: %w", err)
|
return fmt.Errorf("set embeds metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
|
err = ApplyFacturXStub(ctx, engine, facturX, facturxXmlPath, convertOutputPaths)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("apply Factur-X: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = EncryptPdfStub(ctx, engine, encrypt, convertOutputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1224,20 +1483,26 @@ func encryptRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
ctx := c.Get("context").(*api.Context)
|
ctx := c.Get("context").(*api.Context)
|
||||||
|
|
||||||
form := ctx.FormData()
|
form := ctx.FormData()
|
||||||
|
encrypt := FormDataPdfEncrypt(form)
|
||||||
|
|
||||||
var inputPaths []string
|
var inputPaths []string
|
||||||
var userPassword string
|
|
||||||
var ownerPassword string
|
|
||||||
err := form.
|
err := form.
|
||||||
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
||||||
MandatoryString("userPassword", &userPassword).
|
|
||||||
String("ownerPassword", &ownerPassword, "").
|
|
||||||
Validate()
|
Validate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate form data: %w", err)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, inputPaths)
|
// At least one password is required; an empty user password with an
|
||||||
|
// owner password yields an owner-only document.
|
||||||
|
if encrypt.UserPassword == "" && encrypt.OwnerPassword == "" {
|
||||||
|
return api.WrapError(
|
||||||
|
errors.New("no password provided"),
|
||||||
|
api.NewSentinelHttpError(http.StatusBadRequest, "Invalid form data: a 'userPassword' or 'ownerPassword' is required"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = EncryptPdfStub(ctx, engine, encrypt, inputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1265,6 +1530,7 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
form := ctx.FormData()
|
form := ctx.FormData()
|
||||||
embedPaths := FormDataPdfEmbeds(form)
|
embedPaths := FormDataPdfEmbeds(form)
|
||||||
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
||||||
|
facturX, facturxXmlPath := FormDataPdfFacturX(form)
|
||||||
|
|
||||||
var inputPaths []string
|
var inputPaths []string
|
||||||
err := form.
|
err := form.
|
||||||
@@ -1273,17 +1539,36 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate form data: %w", err)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
}
|
}
|
||||||
err = EmbedFilesStub(ctx, engine, embedPaths, inputPaths)
|
|
||||||
|
err = ValidateFacturXCompat(facturX, facturxXmlPath, gotenberg.PdfFormats{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factur-X requires PDF/A-3. Convert when needed; a no-op otherwise,
|
||||||
|
// so a plain embed request keeps its inputs untouched.
|
||||||
|
pdfFormats := FacturXPdfFormats(ctx, engine, facturX, gotenberg.PdfFormats{}, false, inputPaths)
|
||||||
|
outputPaths, err := ConvertStub(ctx, engine, pdfFormats, inputPaths)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("convert PDFs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = EmbedFilesStub(ctx, engine, embedPaths, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("embed files into PDFs: %w", err)
|
return fmt.Errorf("embed files into PDFs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = EmbedFilesMetadataStub(ctx, engine, embedsMetadata, inputPaths)
|
err = EmbedFilesMetadataStub(ctx, engine, embedsMetadata, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("set embeds metadata: %w", err)
|
return fmt.Errorf("set embeds metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = ctx.AddOutputPaths(inputPaths...)
|
err = ApplyFacturXStub(ctx, engine, facturX, facturxXmlPath, outputPaths)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("apply Factur-X: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ctx.AddOutputPaths(outputPaths...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("add output paths: %w", err)
|
return fmt.Errorf("add output paths: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1413,3 +1698,61 @@ func rotateRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// facturXRoute returns an [api.Route] which turns existing PDFs into Factur-X
|
||||||
|
// documents: it ensures PDF/A-3, embeds the CII invoice XML, and injects the fx
|
||||||
|
// XMP metadata.
|
||||||
|
func facturXRoute(engine gotenberg.PdfEngine) api.Route {
|
||||||
|
return api.Route{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/forms/pdfengines/factur-x",
|
||||||
|
IsMultipart: true,
|
||||||
|
Handler: func(c echo.Context) error {
|
||||||
|
ctx := c.Get("context").(*api.Context)
|
||||||
|
|
||||||
|
form := ctx.FormData()
|
||||||
|
pdfFormats := FormDataPdfFormats(form)
|
||||||
|
facturX, facturxXmlPath := FormDataPdfFacturX(form)
|
||||||
|
|
||||||
|
var inputPaths []string
|
||||||
|
err := form.
|
||||||
|
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
||||||
|
Validate()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factur-X is the whole point of this route, so both fields are
|
||||||
|
// mandatory here.
|
||||||
|
if facturX.ConformanceLevel == "" || facturxXmlPath == "" {
|
||||||
|
return api.WrapError(
|
||||||
|
errors.New("facturxConformanceLevel and facturxXml are required"),
|
||||||
|
api.NewSentinelHttpError(http.StatusBadRequest, "Invalid form data: 'facturxConformanceLevel' and 'facturxXml' are both required"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ValidateFacturXCompat(facturX, facturxXmlPath, pdfFormats)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
pdfFormats = FacturXPdfFormats(ctx, engine, facturX, pdfFormats, false, inputPaths)
|
||||||
|
outputPaths, err := ConvertStub(ctx, engine, pdfFormats, inputPaths)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("convert PDFs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ApplyFacturXStub(ctx, engine, facturX, facturxXmlPath, outputPaths)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("apply Factur-X: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ctx.AddOutputPaths(outputPaths...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("add output paths: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.opentelemetry.io/otel/codes"
|
"go.opentelemetry.io/otel/codes"
|
||||||
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
||||||
"go.opentelemetry.io/otel/trace"
|
"go.opentelemetry.io/otel/trace"
|
||||||
@@ -26,6 +28,9 @@ func init() {
|
|||||||
// interface.
|
// interface.
|
||||||
type PdfTk struct {
|
type PdfTk struct {
|
||||||
binPath string
|
binPath string
|
||||||
|
|
||||||
|
version string
|
||||||
|
versionOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
// Descriptor returns a [PdfTk]'s module descriptor.
|
// Descriptor returns a [PdfTk]'s module descriptor.
|
||||||
@@ -60,32 +65,58 @@ func (engine *PdfTk) Validate() error {
|
|||||||
|
|
||||||
// Debug returns additional debug data.
|
// Debug returns additional debug data.
|
||||||
func (engine *PdfTk) Debug() map[string]any {
|
func (engine *PdfTk) Debug() map[string]any {
|
||||||
debug := make(map[string]any)
|
return map[string]any{"version": engine.detectVersion()}
|
||||||
|
}
|
||||||
|
|
||||||
cmd := exec.Command(engine.binPath, "--version") //nolint:gosec
|
// detectVersion resolves the PDFtk version once, preferring the value captured
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
// at image build time so it never spawns the PDFtk JVM at runtime. It falls
|
||||||
|
// back to running pdftk --version for local or non-Docker builds.
|
||||||
|
func (engine *PdfTk) detectVersion() string {
|
||||||
|
engine.versionOnce.Do(func() {
|
||||||
|
if v, ok := gotenberg.BuildVersion("pdftk"); ok {
|
||||||
|
engine.version = v
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
output, err := cmd.Output()
|
cmd := exec.Command(engine.binPath, "--version") //nolint:gosec
|
||||||
if err != nil {
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
debug["version"] = err.Error()
|
|
||||||
return debug
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
engine.version = err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := bytes.SplitN(output, []byte("\n"), 2)
|
||||||
|
if len(lines) > 0 {
|
||||||
|
engine.version = string(lines[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.version = "Unable to determine PDFtk version"
|
||||||
|
})
|
||||||
|
|
||||||
|
return engine.version
|
||||||
|
}
|
||||||
|
|
||||||
|
// spanAttrs returns the client-span attributes for a PDFtk invocation: the
|
||||||
|
// server address and the PDFtk version, plus any extra attributes. The version
|
||||||
|
// rides on every span so a trace records which PDFtk ran the operation.
|
||||||
|
func (engine *PdfTk) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue {
|
||||||
|
attrs := make([]attribute.KeyValue, 0, 2+len(extra))
|
||||||
|
attrs = append(attrs, semconv.ServerAddress(engine.binPath))
|
||||||
|
if v := engine.detectVersion(); v != "" {
|
||||||
|
attrs = append(attrs, attribute.String("gotenberg.pdftk.version", v))
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := bytes.SplitN(output, []byte("\n"), 2)
|
return append(attrs, extra...)
|
||||||
if len(lines) > 0 {
|
|
||||||
debug["version"] = string(lines[0])
|
|
||||||
} else {
|
|
||||||
debug["version"] = "Unable to determine PDFtk version"
|
|
||||||
}
|
|
||||||
|
|
||||||
return debug
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split splits a given PDF file.
|
// Split splits a given PDF file.
|
||||||
func (engine *PdfTk) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
func (engine *PdfTk) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Split",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Split",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -132,7 +163,7 @@ func (engine *PdfTk) Split(ctx context.Context, logger *slog.Logger, mode gotenb
|
|||||||
func (engine *PdfTk) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
func (engine *PdfTk) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Merge",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Merge",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -164,7 +195,7 @@ func (engine *PdfTk) Merge(ctx context.Context, logger *slog.Logger, inputPaths
|
|||||||
func (engine *PdfTk) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
|
func (engine *PdfTk) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.Flatten",
|
_, span := gotenberg.Tracer().Start(ctx, "pdftk.Flatten",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -178,7 +209,7 @@ func (engine *PdfTk) Flatten(ctx context.Context, logger *slog.Logger, inputPath
|
|||||||
func (engine *PdfTk) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
func (engine *PdfTk) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.Convert",
|
_, span := gotenberg.Tracer().Start(ctx, "pdftk.Convert",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -192,7 +223,7 @@ func (engine *PdfTk) Convert(ctx context.Context, logger *slog.Logger, formats g
|
|||||||
func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.ReadMetadata",
|
_, span := gotenberg.Tracer().Start(ctx, "pdftk.ReadMetadata",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -206,7 +237,7 @@ func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *slog.Logger, inpu
|
|||||||
func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
|
func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.WriteMetadata",
|
_, span := gotenberg.Tracer().Start(ctx, "pdftk.WriteMetadata",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -220,7 +251,7 @@ func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *slog.Logger, met
|
|||||||
func (engine *PdfTk) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
|
func (engine *PdfTk) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.PageCount",
|
_, span := gotenberg.Tracer().Start(ctx, "pdftk.PageCount",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -234,7 +265,7 @@ func (engine *PdfTk) PageCount(ctx context.Context, logger *slog.Logger, inputPa
|
|||||||
func (engine *PdfTk) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
|
func (engine *PdfTk) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.WriteBookmarks",
|
_, span := gotenberg.Tracer().Start(ctx, "pdftk.WriteBookmarks",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -248,7 +279,7 @@ func (engine *PdfTk) WriteBookmarks(ctx context.Context, logger *slog.Logger, in
|
|||||||
func (engine *PdfTk) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
|
func (engine *PdfTk) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.ReadBookmarks",
|
_, span := gotenberg.Tracer().Start(ctx, "pdftk.ReadBookmarks",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -259,21 +290,21 @@ func (engine *PdfTk) ReadBookmarks(ctx context.Context, logger *slog.Logger, inp
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt adds password protection to a PDF file using PDFtk.
|
// Encrypt adds password protection to a PDF file using PDFtk.
|
||||||
func (engine *PdfTk) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
|
func (engine *PdfTk) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Encrypt",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Encrypt",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
if userPassword == "" {
|
if opts.UserPassword == "" || opts.Permissions.Restricted() {
|
||||||
err := errors.New("user password cannot be empty")
|
err := gotenberg.NewPdfEngineInvalidArgs("pdftk", "owner-only encryption and permission restrictions are not supported; consider switching to another PDF engine (e.g. qpdf)")
|
||||||
span.RecordError(err)
|
span.RecordError(err)
|
||||||
span.SetStatus(codes.Error, err.Error())
|
span.SetStatus(codes.Error, err.Error())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if ownerPassword == userPassword || ownerPassword == "" {
|
if opts.OwnerPassword == opts.UserPassword || opts.OwnerPassword == "" {
|
||||||
err := gotenberg.NewPdfEngineInvalidArgs("pdftk", "both 'userPassword' and 'ownerPassword' must be provided and different. Consider switching to another PDF engine if this behavior does not work with your workflow")
|
err := gotenberg.NewPdfEngineInvalidArgs("pdftk", "both 'userPassword' and 'ownerPassword' must be provided and different. Consider switching to another PDF engine if this behavior does not work with your workflow")
|
||||||
span.RecordError(err)
|
span.RecordError(err)
|
||||||
span.SetStatus(codes.Error, err.Error())
|
span.SetStatus(codes.Error, err.Error())
|
||||||
@@ -287,8 +318,8 @@ func (engine *PdfTk) Encrypt(ctx context.Context, logger *slog.Logger, inputPath
|
|||||||
args = append(args, inputPath)
|
args = append(args, inputPath)
|
||||||
args = append(args, "output", tmpPath)
|
args = append(args, "output", tmpPath)
|
||||||
args = append(args, "encrypt_128bit")
|
args = append(args, "encrypt_128bit")
|
||||||
args = append(args, "user_pw", userPassword)
|
args = append(args, "user_pw", opts.UserPassword)
|
||||||
args = append(args, "owner_pw", ownerPassword)
|
args = append(args, "owner_pw", opts.OwnerPassword)
|
||||||
|
|
||||||
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -322,7 +353,7 @@ func (engine *PdfTk) Encrypt(ctx context.Context, logger *slog.Logger, inputPath
|
|||||||
func (engine *PdfTk) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
func (engine *PdfTk) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.EmbedFiles",
|
_, span := gotenberg.Tracer().Start(ctx, "pdftk.EmbedFiles",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -339,7 +370,7 @@ func (engine *PdfTk) EmbedFiles(ctx context.Context, logger *slog.Logger, filePa
|
|||||||
func (engine *PdfTk) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
func (engine *PdfTk) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Watermark",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Watermark",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -389,7 +420,7 @@ func (engine *PdfTk) Watermark(ctx context.Context, logger *slog.Logger, inputPa
|
|||||||
func (engine *PdfTk) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
func (engine *PdfTk) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Stamp",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Stamp",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -438,7 +469,7 @@ func (engine *PdfTk) Stamp(ctx context.Context, logger *slog.Logger, inputPath s
|
|||||||
func (engine *PdfTk) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
|
func (engine *PdfTk) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Rotate",
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Rotate",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -500,6 +531,16 @@ func (engine *PdfTk) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger
|
|||||||
return fmt.Errorf("set embeds metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
return fmt.Errorf("set embeds metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InjectFacturXXMP is not available in this implementation.
|
||||||
|
func (engine *PdfTk) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error {
|
||||||
|
return fmt.Errorf("inject Factur-X XMP with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadPdfAConformance is not available in this implementation.
|
||||||
|
func (engine *PdfTk) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
|
||||||
|
return "", "", fmt.Errorf("read PDF/A conformance with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
// Interface guards.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ gotenberg.Module = (*PdfTk)(nil)
|
_ gotenberg.Module = (*PdfTk)(nil)
|
||||||
|
|||||||
76
pkg/modules/qpdf/encrypt_test.go
Normal file
76
pkg/modules/qpdf/encrypt_test.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package qpdf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQpdfPermissionArgs(t *testing.T) {
|
||||||
|
allAllowed := gotenberg.PdfPermissions{
|
||||||
|
AllowPrinting: true,
|
||||||
|
AllowCopying: true,
|
||||||
|
AllowModifying: true,
|
||||||
|
AllowAnnotating: true,
|
||||||
|
AllowFillingForms: true,
|
||||||
|
AllowAssembling: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
perms gotenberg.PdfPermissions
|
||||||
|
expect []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "all allowed yields no flags",
|
||||||
|
perms: allAllowed,
|
||||||
|
expect: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "printing denied",
|
||||||
|
perms: gotenberg.PdfPermissions{
|
||||||
|
AllowPrinting: false,
|
||||||
|
AllowCopying: true,
|
||||||
|
AllowModifying: true,
|
||||||
|
AllowAnnotating: true,
|
||||||
|
AllowFillingForms: true,
|
||||||
|
AllowAssembling: true,
|
||||||
|
},
|
||||||
|
expect: []string{
|
||||||
|
"--print=none",
|
||||||
|
"--extract=y",
|
||||||
|
"--modify-other=y",
|
||||||
|
"--annotate=y",
|
||||||
|
"--form=y",
|
||||||
|
"--assemble=y",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "copying denied",
|
||||||
|
perms: gotenberg.PdfPermissions{
|
||||||
|
AllowPrinting: true,
|
||||||
|
AllowCopying: false,
|
||||||
|
AllowModifying: true,
|
||||||
|
AllowAnnotating: true,
|
||||||
|
AllowFillingForms: true,
|
||||||
|
AllowAssembling: true,
|
||||||
|
},
|
||||||
|
expect: []string{
|
||||||
|
"--print=full",
|
||||||
|
"--extract=n",
|
||||||
|
"--modify-other=y",
|
||||||
|
"--annotate=y",
|
||||||
|
"--form=y",
|
||||||
|
"--assemble=y",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
got := qpdfPermissionArgs(tc.perms)
|
||||||
|
if !reflect.DeepEqual(got, tc.expect) {
|
||||||
|
t.Errorf("expected %v but got %v", tc.expect, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
299
pkg/modules/qpdf/facturx_test.go
Normal file
299
pkg/modules/qpdf/facturx_test.go
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
package qpdf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateFacturX(t *testing.T) {
|
||||||
|
valid := gotenberg.FacturX{
|
||||||
|
ConformanceLevel: gotenberg.FacturXConformanceEN16931,
|
||||||
|
DocumentType: gotenberg.FacturXDocumentTypeInvoice,
|
||||||
|
DocumentFileName: "factur-x.xml",
|
||||||
|
Version: "1.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(f *gotenberg.FacturX)
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{name: "valid", mutate: func(*gotenberg.FacturX) {}},
|
||||||
|
{
|
||||||
|
name: "valid BASIC WL conformance",
|
||||||
|
mutate: func(f *gotenberg.FacturX) { f.ConformanceLevel = gotenberg.FacturXConformanceBasicWL },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid ORDER document type",
|
||||||
|
mutate: func(f *gotenberg.FacturX) { f.DocumentType = gotenberg.FacturXDocumentTypeOrder },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unsupported conformance level",
|
||||||
|
mutate: func(f *gotenberg.FacturX) { f.ConformanceLevel = "FOO" },
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty conformance level",
|
||||||
|
mutate: func(f *gotenberg.FacturX) { f.ConformanceLevel = "" },
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unsupported document type",
|
||||||
|
mutate: func(f *gotenberg.FacturX) { f.DocumentType = "RECEIPT" },
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty document file name",
|
||||||
|
mutate: func(f *gotenberg.FacturX) { f.DocumentFileName = "" },
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty version",
|
||||||
|
mutate: func(f *gotenberg.FacturX) { f.Version = "" },
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
facturX := valid
|
||||||
|
tt.mutate(&facturX)
|
||||||
|
|
||||||
|
err := validateFacturX(facturX)
|
||||||
|
if tt.wantError {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gotenberg.ErrPdfFacturXValueNotSupported) {
|
||||||
|
t.Errorf("expected ErrPdfFacturXValueNotSupported, got %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindMetadataStream(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantKey string
|
||||||
|
wantXMP string
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "metadata stream found",
|
||||||
|
// "PFhtcD4=" is base64 for "<xmp>".
|
||||||
|
input: `{"qpdf":[{},{"obj:1 0 R":{"value":{"/Type":"/Catalog","/Metadata":"4 0 R"}},"obj:4 0 R":{"stream":{"dict":{"/Type":"/Metadata","/Subtype":"/XML"},"data":"PHhtcD4="}}}]}`,
|
||||||
|
wantKey: "obj:4 0 R",
|
||||||
|
wantXMP: "<xmp>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "catalog without metadata reference",
|
||||||
|
input: `{"qpdf":[{},{"obj:1 0 R":{"value":{"/Type":"/Catalog"}}}]}`,
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "metadata object missing",
|
||||||
|
input: `{"qpdf":[{},{"obj:1 0 R":{"value":{"/Type":"/Catalog","/Metadata":"4 0 R"}}}]}`,
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "metadata object is not a stream",
|
||||||
|
input: `{"qpdf":[{},{"obj:1 0 R":{"value":{"/Type":"/Catalog","/Metadata":"4 0 R"}},"obj:4 0 R":{"value":{"/Type":"/Metadata"}}}]}`,
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
objects, err := parsePdfObjects([]byte(tt.input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse objects: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key, dict, xmp, err := findMetadataStream(objects)
|
||||||
|
if tt.wantError {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error, got nil")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if key != tt.wantKey {
|
||||||
|
t.Errorf("key = %q, want %q", key, tt.wantKey)
|
||||||
|
}
|
||||||
|
if xmp != tt.wantXMP {
|
||||||
|
t.Errorf("xmp = %q, want %q", xmp, tt.wantXMP)
|
||||||
|
}
|
||||||
|
if dict == nil {
|
||||||
|
t.Error("expected a non-nil dict")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInjectFacturXIntoXMP(t *testing.T) {
|
||||||
|
facturX := gotenberg.FacturX{
|
||||||
|
ConformanceLevel: gotenberg.FacturXConformanceEN16931,
|
||||||
|
DocumentType: gotenberg.FacturXDocumentTypeInvoice,
|
||||||
|
DocumentFileName: "factur-x.xml",
|
||||||
|
Version: "1.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
// The packet a LibreOffice PDF/A-3b export produces: no pdfaExtension bag.
|
||||||
|
libreOfficeXMP := `<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||||||
|
<x:xmpmeta xmlns:x="adobe:ns:meta/">
|
||||||
|
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||||
|
<rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/">
|
||||||
|
<pdfaid:part>3</pdfaid:part>
|
||||||
|
<pdfaid:conformance>B</pdfaid:conformance>
|
||||||
|
</rdf:Description>
|
||||||
|
</rdf:RDF>
|
||||||
|
</x:xmpmeta>
|
||||||
|
<?xpacket end="w"?>`
|
||||||
|
|
||||||
|
t.Run("creates extension schema when bag absent", func(t *testing.T) {
|
||||||
|
got, changed := injectFacturXIntoXMP(libreOfficeXMP, facturX)
|
||||||
|
if !changed {
|
||||||
|
t.Fatal("expected changed = true")
|
||||||
|
}
|
||||||
|
assertContains(t, got, facturXNamespaceURI)
|
||||||
|
assertContains(t, got, "<fx:ConformanceLevel>EN 16931</fx:ConformanceLevel>")
|
||||||
|
assertContains(t, got, "<fx:DocumentType>INVOICE</fx:DocumentType>")
|
||||||
|
assertContains(t, got, "<fx:DocumentFileName>factur-x.xml</fx:DocumentFileName>")
|
||||||
|
assertContains(t, got, "<fx:Version>1.0</fx:Version>")
|
||||||
|
assertContains(t, got, "pdfaExtension:schemas")
|
||||||
|
assertContains(t, got, "http://www.aiim.org/pdfa/ns/extension/")
|
||||||
|
// The fx blocks must land inside the RDF container.
|
||||||
|
if strings.Index(got, facturXNamespaceURI) > strings.LastIndex(got, "</rdf:RDF>") {
|
||||||
|
t.Error("fx content injected outside the rdf:RDF container")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("idempotent when fx already present", func(t *testing.T) {
|
||||||
|
once, _ := injectFacturXIntoXMP(libreOfficeXMP, facturX)
|
||||||
|
twice, changed := injectFacturXIntoXMP(once, facturX)
|
||||||
|
if changed {
|
||||||
|
t.Error("expected changed = false on a packet that already declares fx")
|
||||||
|
}
|
||||||
|
if twice != once {
|
||||||
|
t.Error("expected the packet to be left untouched")
|
||||||
|
}
|
||||||
|
if strings.Count(twice, "<pdfaExtension:schemas>") != 1 {
|
||||||
|
t.Error("expected exactly one pdfaExtension:schemas declaration")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("appends entry to an existing bag", func(t *testing.T) {
|
||||||
|
withBag := `<x:xmpmeta xmlns:x="adobe:ns:meta/">
|
||||||
|
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||||
|
<rdf:Description rdf:about="" xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#" xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#">
|
||||||
|
<pdfaExtension:schemas>
|
||||||
|
<rdf:Bag>
|
||||||
|
<rdf:li rdf:parseType="Resource"><pdfaSchema:prefix>other</pdfaSchema:prefix></rdf:li>
|
||||||
|
</rdf:Bag>
|
||||||
|
</pdfaExtension:schemas>
|
||||||
|
</rdf:Description>
|
||||||
|
</rdf:RDF>
|
||||||
|
</x:xmpmeta>`
|
||||||
|
got, changed := injectFacturXIntoXMP(withBag, facturX)
|
||||||
|
if !changed {
|
||||||
|
t.Fatal("expected changed = true")
|
||||||
|
}
|
||||||
|
assertContains(t, got, facturXNamespaceURI)
|
||||||
|
if strings.Count(got, "<pdfaExtension:schemas>") != 1 {
|
||||||
|
t.Error("expected the existing pdfaExtension:schemas bag to be reused, not duplicated")
|
||||||
|
}
|
||||||
|
if strings.Count(got, "<pdfaSchema:prefix>") != 2 {
|
||||||
|
t.Error("expected both the existing and the fx schema entries")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no rdf:RDF anchor leaves packet unchanged", func(t *testing.T) {
|
||||||
|
got, changed := injectFacturXIntoXMP("not an xmp packet", facturX)
|
||||||
|
if changed {
|
||||||
|
t.Error("expected changed = false")
|
||||||
|
}
|
||||||
|
if got != "not an xmp packet" {
|
||||||
|
t.Error("expected the packet to be left untouched")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("escapes runtime values", func(t *testing.T) {
|
||||||
|
escaped := facturX
|
||||||
|
escaped.DocumentFileName = "a&b<c>.xml"
|
||||||
|
got, _ := injectFacturXIntoXMP(libreOfficeXMP, escaped)
|
||||||
|
assertContains(t, got, "a&b<c>.xml")
|
||||||
|
if strings.Contains(got, "<fx:DocumentFileName>a&b<c>.xml") {
|
||||||
|
t.Error("expected the document file name to be XML-escaped")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertContains(t *testing.T, haystack, needle string) {
|
||||||
|
t.Helper()
|
||||||
|
if !strings.Contains(haystack, needle) {
|
||||||
|
t.Errorf("expected output to contain %q", needle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePdfAId(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
xmp string
|
||||||
|
expectPart string
|
||||||
|
expectConform string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "element form",
|
||||||
|
xmp: `<rdf:Description><pdfaid:part>3</pdfaid:part><pdfaid:conformance>B</pdfaid:conformance></rdf:Description>`,
|
||||||
|
expectPart: "3",
|
||||||
|
expectConform: "B",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "attribute form",
|
||||||
|
xmp: `<rdf:Description pdfaid:part="3" pdfaid:conformance="U"></rdf:Description>`,
|
||||||
|
expectPart: "3",
|
||||||
|
expectConform: "U",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "part 2",
|
||||||
|
xmp: `<pdfaid:part>2</pdfaid:part><pdfaid:conformance>B</pdfaid:conformance>`,
|
||||||
|
expectPart: "2",
|
||||||
|
expectConform: "B",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "no pdfa identification",
|
||||||
|
xmp: `<rdf:Description><dc:title>foo</dc:title></rdf:Description>`,
|
||||||
|
expectPart: "",
|
||||||
|
expectConform: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "empty packet",
|
||||||
|
xmp: "",
|
||||||
|
expectPart: "",
|
||||||
|
expectConform: "",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
part, conformance := parsePdfAId(tc.xmp)
|
||||||
|
if part != tc.expectPart {
|
||||||
|
t.Errorf("expected part %q but got %q", tc.expectPart, part)
|
||||||
|
}
|
||||||
|
if conformance != tc.expectConform {
|
||||||
|
t.Errorf("expected conformance %q but got %q", tc.expectConform, conformance)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,16 +3,21 @@ package qpdf
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.opentelemetry.io/otel/codes"
|
"go.opentelemetry.io/otel/codes"
|
||||||
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
||||||
"go.opentelemetry.io/otel/trace"
|
"go.opentelemetry.io/otel/trace"
|
||||||
@@ -29,6 +34,9 @@ func init() {
|
|||||||
type QPdf struct {
|
type QPdf struct {
|
||||||
binPath string
|
binPath string
|
||||||
globalArgs []string
|
globalArgs []string
|
||||||
|
|
||||||
|
version string
|
||||||
|
versionOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
// Descriptor returns a [QPdf]'s module descriptor.
|
// Descriptor returns a [QPdf]'s module descriptor.
|
||||||
@@ -43,7 +51,7 @@ func (engine *QPdf) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
func (engine *QPdf) Provision(ctx *gotenberg.Context) error {
|
func (engine *QPdf) Provision(ctx *gotenberg.Context) error {
|
||||||
binPath, ok := os.LookupEnv("QPDF_BIN_PATH")
|
binPath, ok := os.LookupEnv("QPDF_BIN_PATH")
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.New("QPDF_BIN_PATH environment variable is not set")
|
return errors.New("QPDF_BIN_PATH environment variable is not set; set it to the absolute path of the qpdf binary")
|
||||||
}
|
}
|
||||||
|
|
||||||
engine.binPath = binPath
|
engine.binPath = binPath
|
||||||
@@ -65,32 +73,58 @@ func (engine *QPdf) Validate() error {
|
|||||||
|
|
||||||
// Debug returns additional debug data.
|
// Debug returns additional debug data.
|
||||||
func (engine *QPdf) Debug() map[string]any {
|
func (engine *QPdf) Debug() map[string]any {
|
||||||
debug := make(map[string]any)
|
return map[string]any{"version": engine.detectVersion()}
|
||||||
|
}
|
||||||
|
|
||||||
cmd := exec.Command(engine.binPath, "--version") //nolint:gosec
|
// detectVersion resolves the qpdf version once, preferring the value captured
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
// at image build time so it never spawns qpdf at runtime. It falls back to
|
||||||
|
// running qpdf --version for local or non-Docker builds.
|
||||||
|
func (engine *QPdf) detectVersion() string {
|
||||||
|
engine.versionOnce.Do(func() {
|
||||||
|
if v, ok := gotenberg.BuildVersion("qpdf"); ok {
|
||||||
|
engine.version = v
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
output, err := cmd.Output()
|
cmd := exec.Command(engine.binPath, "--version") //nolint:gosec
|
||||||
if err != nil {
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
debug["version"] = err.Error()
|
|
||||||
return debug
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
engine.version = err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := bytes.SplitN(output, []byte("\n"), 2)
|
||||||
|
if len(lines) > 0 {
|
||||||
|
engine.version = string(lines[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.version = "Unable to determine QPDF version"
|
||||||
|
})
|
||||||
|
|
||||||
|
return engine.version
|
||||||
|
}
|
||||||
|
|
||||||
|
// spanAttrs returns the client-span attributes for a qpdf invocation: the
|
||||||
|
// server address and the qpdf version, plus any extra attributes. The version
|
||||||
|
// rides on every span so a trace records which qpdf ran the operation.
|
||||||
|
func (engine *QPdf) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue {
|
||||||
|
attrs := make([]attribute.KeyValue, 0, 2+len(extra))
|
||||||
|
attrs = append(attrs, semconv.ServerAddress(engine.binPath))
|
||||||
|
if v := engine.detectVersion(); v != "" {
|
||||||
|
attrs = append(attrs, attribute.String("gotenberg.qpdf.version", v))
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := bytes.SplitN(output, []byte("\n"), 2)
|
return append(attrs, extra...)
|
||||||
if len(lines) > 0 {
|
|
||||||
debug["version"] = string(lines[0])
|
|
||||||
} else {
|
|
||||||
debug["version"] = "Unable to determine QPDF version"
|
|
||||||
}
|
|
||||||
|
|
||||||
return debug
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split splits a given PDF file.
|
// Split splits a given PDF file.
|
||||||
func (engine *QPdf) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
func (engine *QPdf) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Split",
|
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Split",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -140,7 +174,7 @@ func (engine *QPdf) Split(ctx context.Context, logger *slog.Logger, mode gotenbe
|
|||||||
func (engine *QPdf) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
func (engine *QPdf) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Merge",
|
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Merge",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -176,7 +210,7 @@ func (engine *QPdf) Merge(ctx context.Context, logger *slog.Logger, inputPaths [
|
|||||||
func (engine *QPdf) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
|
func (engine *QPdf) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Flatten",
|
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Flatten",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -211,7 +245,7 @@ func (engine *QPdf) Flatten(ctx context.Context, logger *slog.Logger, inputPath
|
|||||||
func (engine *QPdf) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
func (engine *QPdf) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Convert",
|
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Convert",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -225,7 +259,7 @@ func (engine *QPdf) Convert(ctx context.Context, logger *slog.Logger, formats go
|
|||||||
func (engine *QPdf) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
func (engine *QPdf) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadMetadata",
|
_, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadMetadata",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -239,7 +273,7 @@ func (engine *QPdf) ReadMetadata(ctx context.Context, logger *slog.Logger, input
|
|||||||
func (engine *QPdf) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
|
func (engine *QPdf) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.WriteMetadata",
|
_, span := gotenberg.Tracer().Start(ctx, "qpdf.WriteMetadata",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -253,7 +287,7 @@ func (engine *QPdf) WriteMetadata(ctx context.Context, logger *slog.Logger, meta
|
|||||||
func (engine *QPdf) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
|
func (engine *QPdf) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.PageCount",
|
_, span := gotenberg.Tracer().Start(ctx, "qpdf.PageCount",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -267,7 +301,7 @@ func (engine *QPdf) PageCount(ctx context.Context, logger *slog.Logger, inputPat
|
|||||||
func (engine *QPdf) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
|
func (engine *QPdf) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.WriteBookmarks",
|
_, span := gotenberg.Tracer().Start(ctx, "qpdf.WriteBookmarks",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -281,7 +315,7 @@ func (engine *QPdf) WriteBookmarks(ctx context.Context, logger *slog.Logger, inp
|
|||||||
func (engine *QPdf) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
|
func (engine *QPdf) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadBookmarks",
|
_, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadBookmarks",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -292,29 +326,63 @@ func (engine *QPdf) ReadBookmarks(ctx context.Context, logger *slog.Logger, inpu
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt adds password protection to a PDF file using QPDF.
|
// Encrypt adds password protection to a PDF file using QPDF.
|
||||||
func (engine *QPdf) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
|
// qpdfPermissionArgs maps PDF permissions to QPDF --encrypt restriction flags.
|
||||||
|
// It returns nil when every permission is allowed, matching QPDF's default
|
||||||
|
// (all actions permitted).
|
||||||
|
func qpdfPermissionArgs(p gotenberg.PdfPermissions) []string {
|
||||||
|
if !p.Restricted() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
yn := func(allowed bool) string {
|
||||||
|
if allowed {
|
||||||
|
return "y"
|
||||||
|
}
|
||||||
|
return "n"
|
||||||
|
}
|
||||||
|
|
||||||
|
print := "full"
|
||||||
|
if !p.AllowPrinting {
|
||||||
|
print = "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
return []string{
|
||||||
|
"--print=" + print,
|
||||||
|
"--extract=" + yn(p.AllowCopying),
|
||||||
|
"--modify-other=" + yn(p.AllowModifying),
|
||||||
|
"--annotate=" + yn(p.AllowAnnotating),
|
||||||
|
"--form=" + yn(p.AllowFillingForms),
|
||||||
|
"--assemble=" + yn(p.AllowAssembling),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (engine *QPdf) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Encrypt",
|
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Encrypt",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
if userPassword == "" {
|
ownerPassword := opts.OwnerPassword
|
||||||
err := errors.New("user password cannot be empty")
|
if ownerPassword == "" {
|
||||||
|
ownerPassword = opts.UserPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty user password is allowed: it produces an owner-only document.
|
||||||
|
if opts.UserPassword == "" && ownerPassword == "" {
|
||||||
|
err := errors.New("at least a user or owner password is required")
|
||||||
span.RecordError(err)
|
span.RecordError(err)
|
||||||
span.SetStatus(codes.Error, err.Error())
|
span.SetStatus(codes.Error, err.Error())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if ownerPassword == "" {
|
args := make([]string, 0, 14+len(engine.globalArgs))
|
||||||
ownerPassword = userPassword
|
|
||||||
}
|
|
||||||
|
|
||||||
args := make([]string, 0, 7+len(engine.globalArgs))
|
|
||||||
args = append(args, inputPath)
|
args = append(args, inputPath)
|
||||||
args = append(args, engine.globalArgs...)
|
args = append(args, engine.globalArgs...)
|
||||||
args = append(args, "--replace-input")
|
args = append(args, "--replace-input")
|
||||||
args = append(args, "--encrypt", userPassword, ownerPassword, "256", "--")
|
args = append(args, "--encrypt", opts.UserPassword, ownerPassword, "256")
|
||||||
|
args = append(args, qpdfPermissionArgs(opts.Permissions)...)
|
||||||
|
args = append(args, "--")
|
||||||
|
|
||||||
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -340,7 +408,7 @@ func (engine *QPdf) Encrypt(ctx context.Context, logger *slog.Logger, inputPath,
|
|||||||
func (engine *QPdf) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
func (engine *QPdf) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.EmbedFiles",
|
_, span := gotenberg.Tracer().Start(ctx, "qpdf.EmbedFiles",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -357,7 +425,7 @@ func (engine *QPdf) EmbedFiles(ctx context.Context, logger *slog.Logger, filePat
|
|||||||
func (engine *QPdf) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
|
func (engine *QPdf) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.EmbedFilesMetadata",
|
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.EmbedFilesMetadata",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -527,8 +595,10 @@ func patchCatalogAF(catalogRef string, catalogValue map[string]any, filespecRefs
|
|||||||
}
|
}
|
||||||
|
|
||||||
// writeAndApplyUpdate marshals the update objects as QPDF JSON v2, writes
|
// writeAndApplyUpdate marshals the update objects as QPDF JSON v2, writes
|
||||||
// them to a temp file, and applies the update via --update-from-json.
|
// them to a temp file, and applies the update via --update-from-json. extraArgs
|
||||||
func (engine *QPdf) writeAndApplyUpdate(ctx context.Context, logger *slog.Logger, inputPath string, updateObjects map[string]any) error {
|
// are appended to the QPDF command (e.g., --json-stream-data=inline when the
|
||||||
|
// update replaces stream data).
|
||||||
|
func (engine *QPdf) writeAndApplyUpdate(ctx context.Context, logger *slog.Logger, inputPath string, updateObjects map[string]any, extraArgs ...string) error {
|
||||||
updateJSON := map[string]any{
|
updateJSON := map[string]any{
|
||||||
"qpdf": []any{
|
"qpdf": []any{
|
||||||
map[string]any{
|
map[string]any{
|
||||||
@@ -560,9 +630,10 @@ func (engine *QPdf) writeAndApplyUpdate(ctx context.Context, logger *slog.Logger
|
|||||||
return fmt.Errorf("close temp file: %w", err)
|
return fmt.Errorf("close temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateArgs := make([]string, 0, 5+len(engine.globalArgs))
|
updateArgs := make([]string, 0, 5+len(engine.globalArgs)+len(extraArgs))
|
||||||
updateArgs = append(updateArgs, inputPath)
|
updateArgs = append(updateArgs, inputPath)
|
||||||
updateArgs = append(updateArgs, engine.globalArgs...)
|
updateArgs = append(updateArgs, engine.globalArgs...)
|
||||||
|
updateArgs = append(updateArgs, extraArgs...)
|
||||||
updateArgs = append(updateArgs, "--newline-before-endstream")
|
updateArgs = append(updateArgs, "--newline-before-endstream")
|
||||||
updateArgs = append(updateArgs, "--update-from-json="+tmpFile.Name())
|
updateArgs = append(updateArgs, "--update-from-json="+tmpFile.Name())
|
||||||
updateArgs = append(updateArgs, "--replace-input")
|
updateArgs = append(updateArgs, "--replace-input")
|
||||||
@@ -635,11 +706,396 @@ func stripQpdfStringPrefix(s string) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// facturXNamespaceURI is the Factur-X/ZUGFeRD XMP namespace required by strict
|
||||||
|
// validators.
|
||||||
|
const facturXNamespaceURI = "urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#"
|
||||||
|
|
||||||
|
// InjectFacturXXMP injects Factur-X/ZUGFeRD XMP metadata into the document-level
|
||||||
|
// XMP packet (Catalog /Metadata stream) of a PDF/A-3 using QPDF's JSON
|
||||||
|
// manipulation. It reads the existing XMP packet, splices in the fx
|
||||||
|
// rdf:Description plus the PDF/A extension-schema declaration, and writes the
|
||||||
|
// stream back uncompressed so the document stays PDF/A-valid.
|
||||||
|
//
|
||||||
|
// It assumes the input already carries a Catalog /Metadata stream (always true
|
||||||
|
// for a LibreOffice PDF/A export). The injection is idempotent: a packet that
|
||||||
|
// already declares the fx namespace is left untouched.
|
||||||
|
func (engine *QPdf) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error {
|
||||||
|
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.InjectFacturXXMP",
|
||||||
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
|
trace.WithAttributes(engine.spanAttrs(
|
||||||
|
attribute.String("gotenberg.facturx.conformance_level", facturX.ConformanceLevel),
|
||||||
|
attribute.String("gotenberg.facturx.document_type", facturX.DocumentType),
|
||||||
|
)...),
|
||||||
|
)
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
err := validateFacturX(facturX)
|
||||||
|
if err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugContext(ctx, fmt.Sprintf("injecting Factur-X XMP into %s with QPDF", inputPath))
|
||||||
|
|
||||||
|
args := append([]string{inputPath}, engine.globalArgs...)
|
||||||
|
args = append(args, "--newline-before-endstream", "--json-output", "--json-stream-data=inline")
|
||||||
|
|
||||||
|
output, err := engine.execCaptureOutput(ctx, args...)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("get PDF JSON with QPDF: %w", err)
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
objects, err := parsePdfObjects(output)
|
||||||
|
if err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
metaKey, metaDict, xmp, err := findMetadataStream(objects)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("locate XMP metadata stream: %w", err)
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
newXmp, changed := injectFacturXIntoXMP(xmp, facturX)
|
||||||
|
if !changed {
|
||||||
|
logger.DebugContext(ctx, "Factur-X XMP already present, skipping injection")
|
||||||
|
span.SetStatus(codes.Ok, "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDF/A requires the metadata stream to be uncompressed and unfiltered. We
|
||||||
|
// provide the decoded XMP as the new stream data, so any existing filter
|
||||||
|
// must be dropped and the length left for QPDF to recompute.
|
||||||
|
delete(metaDict, "/Filter")
|
||||||
|
delete(metaDict, "/DecodeParms")
|
||||||
|
delete(metaDict, "/Length")
|
||||||
|
|
||||||
|
updateObjects := map[string]any{
|
||||||
|
metaKey: map[string]any{
|
||||||
|
"stream": map[string]any{
|
||||||
|
"dict": metaDict,
|
||||||
|
"data": base64.StdEncoding.EncodeToString([]byte(newXmp)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err = engine.writeAndApplyUpdate(ctx, logger, inputPath, updateObjects, "--json-stream-data=inline")
|
||||||
|
if err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
span.SetStatus(codes.Ok, "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadPdfAConformance reads the PDF/A part and conformance from the
|
||||||
|
// document-level XMP packet (pdfaid:part and pdfaid:conformance) using QPDF's
|
||||||
|
// JSON output. It returns empty strings when the document carries no XMP
|
||||||
|
// metadata stream or no PDF/A identification.
|
||||||
|
func (engine *QPdf) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
|
||||||
|
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadPdfAConformance",
|
||||||
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
|
)
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
logger.DebugContext(ctx, fmt.Sprintf("reading PDF/A conformance from %s with QPDF", inputPath))
|
||||||
|
|
||||||
|
args := append([]string{inputPath}, engine.globalArgs...)
|
||||||
|
args = append(args, "--json-output", "--json-stream-data=inline")
|
||||||
|
|
||||||
|
output, err := engine.execCaptureOutput(ctx, args...)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("get PDF JSON with QPDF: %w", err)
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
objects, err := parsePdfObjects(output)
|
||||||
|
if err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, xmp, err := findMetadataStream(objects)
|
||||||
|
if err != nil {
|
||||||
|
// No XMP metadata stream means no PDF/A identification.
|
||||||
|
logger.DebugContext(ctx, fmt.Sprintf("no XMP metadata stream in %s: %s", inputPath, err))
|
||||||
|
span.SetStatus(codes.Ok, "")
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
part, conformance := parsePdfAId(xmp)
|
||||||
|
span.SetAttributes(
|
||||||
|
attribute.String("gotenberg.pdfa.part", part),
|
||||||
|
attribute.String("gotenberg.pdfa.conformance", conformance),
|
||||||
|
)
|
||||||
|
span.SetStatus(codes.Ok, "")
|
||||||
|
return part, conformance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
pdfaIdPartRe = regexp.MustCompile(`pdfaid:part[\s>="']*([0-9]+)`)
|
||||||
|
pdfaIdConformanceRe = regexp.MustCompile(`pdfaid:conformance[\s>="']*([A-Za-z]+)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// parsePdfAId extracts the PDF/A part and conformance from an XMP packet. It
|
||||||
|
// handles both the element (<pdfaid:part>3</pdfaid:part>) and attribute
|
||||||
|
// (pdfaid:part="3") serializations.
|
||||||
|
func parsePdfAId(xmp string) (part string, conformance string) {
|
||||||
|
if m := pdfaIdPartRe.FindStringSubmatch(xmp); m != nil {
|
||||||
|
part = m[1]
|
||||||
|
}
|
||||||
|
if m := pdfaIdConformanceRe.FindStringSubmatch(xmp); m != nil {
|
||||||
|
conformance = m[1]
|
||||||
|
}
|
||||||
|
return part, conformance
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateFacturX checks the Factur-X fields against the supported values.
|
||||||
|
func validateFacturX(facturX gotenberg.FacturX) error {
|
||||||
|
switch facturX.ConformanceLevel {
|
||||||
|
case gotenberg.FacturXConformanceMinimum,
|
||||||
|
gotenberg.FacturXConformanceBasicWL,
|
||||||
|
gotenberg.FacturXConformanceBasic,
|
||||||
|
gotenberg.FacturXConformanceEN16931,
|
||||||
|
gotenberg.FacturXConformanceExtended,
|
||||||
|
gotenberg.FacturXConformanceXRechnung:
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("conformance level '%s': %w", facturX.ConformanceLevel, gotenberg.ErrPdfFacturXValueNotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch facturX.DocumentType {
|
||||||
|
case gotenberg.FacturXDocumentTypeInvoice,
|
||||||
|
gotenberg.FacturXDocumentTypeOrder,
|
||||||
|
gotenberg.FacturXDocumentTypeOrderResponse,
|
||||||
|
gotenberg.FacturXDocumentTypeOrderChange:
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("document type '%s': %w", facturX.DocumentType, gotenberg.ErrPdfFacturXValueNotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
if facturX.DocumentFileName == "" {
|
||||||
|
return fmt.Errorf("document file name is empty: %w", gotenberg.ErrPdfFacturXValueNotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
if facturX.Version == "" {
|
||||||
|
return fmt.Errorf("version is empty: %w", gotenberg.ErrPdfFacturXValueNotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findMetadataStream locates the document-level XMP metadata stream referenced
|
||||||
|
// by the Catalog /Metadata entry. It returns the object key (e.g. "obj:4 0 R"),
|
||||||
|
// the stream dict, and the decoded XMP packet.
|
||||||
|
func findMetadataStream(objects map[string]json.RawMessage) (string, map[string]any, string, error) {
|
||||||
|
var metadataRef string
|
||||||
|
for _, raw := range objects {
|
||||||
|
var obj map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
valueRaw, ok := obj["value"]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var value map[string]any
|
||||||
|
if err := json.Unmarshal(valueRaw, &value); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if typeVal, _ := value["/Type"].(string); typeVal == "/Catalog" {
|
||||||
|
metadataRef, _ = value["/Metadata"].(string)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadataRef == "" {
|
||||||
|
return "", nil, "", errors.New("no /Metadata reference in the catalog")
|
||||||
|
}
|
||||||
|
|
||||||
|
// References in values use the "4 0 R" form; object keys use "obj:4 0 R".
|
||||||
|
objKey := "obj:" + metadataRef
|
||||||
|
raw, ok := objects[objKey]
|
||||||
|
if !ok {
|
||||||
|
return "", nil, "", fmt.Errorf("metadata object '%s' not found", objKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
var obj map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||||
|
return "", nil, "", fmt.Errorf("unmarshal metadata object: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
streamRaw, ok := obj["stream"]
|
||||||
|
if !ok {
|
||||||
|
return "", nil, "", errors.New("metadata object is not a stream")
|
||||||
|
}
|
||||||
|
|
||||||
|
var stream struct {
|
||||||
|
Dict map[string]any `json:"dict"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(streamRaw, &stream); err != nil {
|
||||||
|
return "", nil, "", fmt.Errorf("unmarshal metadata stream: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(stream.Data)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, "", fmt.Errorf("decode metadata stream data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dict := stream.Dict
|
||||||
|
if dict == nil {
|
||||||
|
dict = make(map[string]any)
|
||||||
|
}
|
||||||
|
|
||||||
|
return objKey, dict, string(decoded), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// injectFacturXIntoXMP splices the fx rdf:Description and the PDF/A
|
||||||
|
// extension-schema declaration into an XMP packet. It returns the new packet and
|
||||||
|
// whether a change was made (false when the fx namespace is already present).
|
||||||
|
func injectFacturXIntoXMP(xmp string, facturX gotenberg.FacturX) (string, bool) {
|
||||||
|
if strings.Contains(xmp, facturXNamespaceURI) {
|
||||||
|
return xmp, false
|
||||||
|
}
|
||||||
|
|
||||||
|
anchor := strings.LastIndex(xmp, "</rdf:RDF>")
|
||||||
|
if anchor == -1 {
|
||||||
|
return xmp, false
|
||||||
|
}
|
||||||
|
|
||||||
|
insert := facturXDescription(facturX)
|
||||||
|
|
||||||
|
if strings.Contains(xmp, "pdfaExtension:schemas") {
|
||||||
|
// An extension-schema bag already exists (e.g. emitted by another tool):
|
||||||
|
// splice the fx Description, then append the fx schema entry into the bag.
|
||||||
|
spliced := xmp[:anchor] + insert + xmp[anchor:]
|
||||||
|
return injectSchemaIntoExistingBag(spliced), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// No extension-schema bag yet (the LibreOffice PDF/A case): create the whole
|
||||||
|
// container alongside the fx Description.
|
||||||
|
insert += facturXExtensionSchema()
|
||||||
|
return xmp[:anchor] + insert + xmp[anchor:], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// injectSchemaIntoExistingBag appends the fx schema entry into an existing
|
||||||
|
// pdfaExtension:schemas bag.
|
||||||
|
func injectSchemaIntoExistingBag(xmp string) string {
|
||||||
|
mi := strings.Index(xmp, "pdfaExtension:schemas")
|
||||||
|
if mi == -1 {
|
||||||
|
return xmp
|
||||||
|
}
|
||||||
|
|
||||||
|
bag := strings.Index(xmp[mi:], "<rdf:Bag")
|
||||||
|
if bag == -1 {
|
||||||
|
return xmp
|
||||||
|
}
|
||||||
|
|
||||||
|
gt := strings.Index(xmp[mi+bag:], ">")
|
||||||
|
if gt == -1 {
|
||||||
|
return xmp
|
||||||
|
}
|
||||||
|
|
||||||
|
pos := mi + bag + gt + 1
|
||||||
|
return xmp[:pos] + "\n" + facturXSchemaLi() + xmp[pos:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// facturXDescription builds the fx rdf:Description carrying the runtime values.
|
||||||
|
func facturXDescription(facturX gotenberg.FacturX) string {
|
||||||
|
return fmt.Sprintf(` <rdf:Description rdf:about="" xmlns:fx="%s">
|
||||||
|
<fx:DocumentType>%s</fx:DocumentType>
|
||||||
|
<fx:DocumentFileName>%s</fx:DocumentFileName>
|
||||||
|
<fx:Version>%s</fx:Version>
|
||||||
|
<fx:ConformanceLevel>%s</fx:ConformanceLevel>
|
||||||
|
</rdf:Description>
|
||||||
|
`,
|
||||||
|
facturXNamespaceURI,
|
||||||
|
xmlEscape(facturX.DocumentType),
|
||||||
|
xmlEscape(facturX.DocumentFileName),
|
||||||
|
xmlEscape(facturX.Version),
|
||||||
|
xmlEscape(facturX.ConformanceLevel),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// facturXExtensionSchema builds the rdf:Description that declares the PDF/A
|
||||||
|
// extension schema for the fx namespace, including the namespace declarations.
|
||||||
|
func facturXExtensionSchema() string {
|
||||||
|
return fmt.Sprintf(` <rdf:Description rdf:about="" xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#" xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#">
|
||||||
|
<pdfaExtension:schemas>
|
||||||
|
<rdf:Bag>
|
||||||
|
%s
|
||||||
|
</rdf:Bag>
|
||||||
|
</pdfaExtension:schemas>
|
||||||
|
</rdf:Description>
|
||||||
|
`, facturXSchemaLi())
|
||||||
|
}
|
||||||
|
|
||||||
|
// facturXSchemaLi builds the rdf:li describing the fx schema and its four
|
||||||
|
// properties. These are fixed schema definitions, not runtime invoice values.
|
||||||
|
func facturXSchemaLi() string {
|
||||||
|
return fmt.Sprintf(` <rdf:li rdf:parseType="Resource">
|
||||||
|
<pdfaSchema:schema>Factur-X PDFA Extension Schema</pdfaSchema:schema>
|
||||||
|
<pdfaSchema:namespaceURI>%s</pdfaSchema:namespaceURI>
|
||||||
|
<pdfaSchema:prefix>fx</pdfaSchema:prefix>
|
||||||
|
<pdfaSchema:property>
|
||||||
|
<rdf:Seq>
|
||||||
|
<rdf:li rdf:parseType="Resource">
|
||||||
|
<pdfaProperty:name>DocumentFileName</pdfaProperty:name>
|
||||||
|
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
||||||
|
<pdfaProperty:category>external</pdfaProperty:category>
|
||||||
|
<pdfaProperty:description>name of the embedded XML invoice file</pdfaProperty:description>
|
||||||
|
</rdf:li>
|
||||||
|
<rdf:li rdf:parseType="Resource">
|
||||||
|
<pdfaProperty:name>DocumentType</pdfaProperty:name>
|
||||||
|
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
||||||
|
<pdfaProperty:category>external</pdfaProperty:category>
|
||||||
|
<pdfaProperty:description>The type of the embedded Factur-X document</pdfaProperty:description>
|
||||||
|
</rdf:li>
|
||||||
|
<rdf:li rdf:parseType="Resource">
|
||||||
|
<pdfaProperty:name>Version</pdfaProperty:name>
|
||||||
|
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
||||||
|
<pdfaProperty:category>external</pdfaProperty:category>
|
||||||
|
<pdfaProperty:description>The actual version of the Factur-X XML schema</pdfaProperty:description>
|
||||||
|
</rdf:li>
|
||||||
|
<rdf:li rdf:parseType="Resource">
|
||||||
|
<pdfaProperty:name>ConformanceLevel</pdfaProperty:name>
|
||||||
|
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
||||||
|
<pdfaProperty:category>external</pdfaProperty:category>
|
||||||
|
<pdfaProperty:description>The conformance level of the embedded Factur-X data</pdfaProperty:description>
|
||||||
|
</rdf:li>
|
||||||
|
</rdf:Seq>
|
||||||
|
</pdfaSchema:property>
|
||||||
|
</rdf:li>`, facturXNamespaceURI)
|
||||||
|
}
|
||||||
|
|
||||||
|
// xmlEscape escapes a string for safe inclusion in XML character data.
|
||||||
|
func xmlEscape(s string) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_ = xml.EscapeText(&buf, []byte(s))
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
// Watermark is not available in this implementation.
|
// Watermark is not available in this implementation.
|
||||||
func (engine *QPdf) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
func (engine *QPdf) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Watermark",
|
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Watermark",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -653,7 +1109,7 @@ func (engine *QPdf) Watermark(ctx context.Context, logger *slog.Logger, inputPat
|
|||||||
func (engine *QPdf) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
func (engine *QPdf) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Stamp",
|
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Stamp",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
@@ -667,7 +1123,7 @@ func (engine *QPdf) Stamp(ctx context.Context, logger *slog.Logger, inputPath st
|
|||||||
func (engine *QPdf) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
|
func (engine *QPdf) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Rotate",
|
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Rotate",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
|
|||||||
58
pkg/modules/qpdf/version_test.go
Normal file
58
pkg/modules/qpdf/version_test.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package qpdf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQPdfDetectVersion(t *testing.T) {
|
||||||
|
t.Run("prefers the build-time version without executing qpdf", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "qpdf"), []byte("qpdf version 11.9.0\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write version file: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv(gotenberg.BuildVersionsDirPathEnvVar, dir)
|
||||||
|
|
||||||
|
// A bogus binPath would error if executed, so a correct result proves
|
||||||
|
// the build-time file is used instead of running qpdf.
|
||||||
|
engine := &QPdf{binPath: "/nonexistent/qpdf"}
|
||||||
|
if got := engine.Debug()["version"]; got != "qpdf version 11.9.0" {
|
||||||
|
t.Errorf("Debug()[version] = %v, want the build-time value", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("falls back to executing qpdf when no build-time version", func(t *testing.T) {
|
||||||
|
t.Setenv(gotenberg.BuildVersionsDirPathEnvVar, "")
|
||||||
|
|
||||||
|
// With no build-time file and a bogus binPath, the exec fallback runs
|
||||||
|
// and records its error rather than a build-time value.
|
||||||
|
engine := &QPdf{binPath: "/nonexistent/qpdf"}
|
||||||
|
if got := engine.Debug()["version"]; got == "qpdf version 11.9.0" {
|
||||||
|
t.Errorf("Debug()[version] = %v, expected the exec fallback", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("exposes the version as a span attribute", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "qpdf"), []byte("qpdf version 12.2.0"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write version file: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv(gotenberg.BuildVersionsDirPathEnvVar, dir)
|
||||||
|
|
||||||
|
// spanAttrs is what gets handed to trace.WithAttributes, so its output
|
||||||
|
// is exactly what a span carries.
|
||||||
|
engine := &QPdf{binPath: "/nonexistent/qpdf"}
|
||||||
|
var got string
|
||||||
|
for _, attr := range engine.spanAttrs() {
|
||||||
|
if attr.Key == "gotenberg.qpdf.version" {
|
||||||
|
got = attr.Value.AsString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got != "qpdf version 12.2.0" {
|
||||||
|
t.Errorf("span attribute gotenberg.qpdf.version = %q, want the build-time value", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
62
pkg/modules/webhook/async_context.go
Normal file
62
pkg/modules/webhook/async_context.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package webhook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// detachAsyncContext detaches ctx from the inbound request lifecycle so the
|
||||||
|
// webhook goroutine survives echo recycling the request, while preserving the
|
||||||
|
// conversion deadline and the caller's trace.
|
||||||
|
//
|
||||||
|
// Echo cancels the request context as soon as the synchronous handler returns
|
||||||
|
// [api.ErrAsyncProcess], which would abort the asynchronous work. Detaching via
|
||||||
|
// [context.WithoutCancel] severs that cancellation while keeping the context
|
||||||
|
// values. [context.WithoutCancel] also drops the deadline, so it is re-layered.
|
||||||
|
//
|
||||||
|
// The server span ends as soon as that handler returns, so its span context is
|
||||||
|
// re-seated as a remote, non-recording parent: the asynchronous worker keeps
|
||||||
|
// the same trace without recording into a span that is about to end. A
|
||||||
|
// worker-root [webhook.Async] span is then opened, linked back to the
|
||||||
|
// originating request span, and stays open for the whole delivery so downstream
|
||||||
|
// conversion and webhook spans have a live parent in the caller's trace.
|
||||||
|
//
|
||||||
|
// The returned cancel function ends the worker span and cleans up both the
|
||||||
|
// detached context and the original working directory.
|
||||||
|
func detachAsyncContext(ctx *api.Context, cancel context.CancelFunc) context.CancelFunc {
|
||||||
|
deadline, hasDeadline := ctx.Deadline()
|
||||||
|
|
||||||
|
serverSpanCtx := trace.SpanContextFromContext(ctx.Context)
|
||||||
|
base := context.WithoutCancel(ctx.Context)
|
||||||
|
if serverSpanCtx.IsValid() {
|
||||||
|
base = trace.ContextWithRemoteSpanContext(base, serverSpanCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
var detachedCtx context.Context
|
||||||
|
var detachedCancel context.CancelFunc
|
||||||
|
if hasDeadline {
|
||||||
|
detachedCtx, detachedCancel = context.WithDeadline(base, deadline)
|
||||||
|
} else {
|
||||||
|
// Fallback if no deadline was set (rare, as newContext enforces it).
|
||||||
|
detachedCtx, detachedCancel = context.WithCancel(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
startOpts := []trace.SpanStartOption{trace.WithSpanKind(trace.SpanKindInternal)}
|
||||||
|
if serverSpanCtx.IsValid() {
|
||||||
|
startOpts = append(startOpts, trace.WithLinks(trace.Link{SpanContext: serverSpanCtx}))
|
||||||
|
}
|
||||||
|
|
||||||
|
workerCtx, workerSpan := gotenberg.Tracer().Start(detachedCtx, "webhook.Async", startOpts...)
|
||||||
|
ctx.Context = workerCtx
|
||||||
|
|
||||||
|
originalCancel := cancel
|
||||||
|
return func() {
|
||||||
|
workerSpan.End()
|
||||||
|
detachedCancel()
|
||||||
|
originalCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
84
pkg/modules/webhook/async_context_test.go
Normal file
84
pkg/modules/webhook/async_context_test.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package webhook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetachAsyncContext_PreservesTraceContext(t *testing.T) {
|
||||||
|
traceID, _ := trace.TraceIDFromHex("0123456789abcdef0123456789abcdef")
|
||||||
|
spanID, _ := trace.SpanIDFromHex("0123456789abcdef")
|
||||||
|
sc := trace.NewSpanContext(trace.SpanContextConfig{
|
||||||
|
TraceID: traceID,
|
||||||
|
SpanID: spanID,
|
||||||
|
TraceFlags: trace.FlagsSampled,
|
||||||
|
Remote: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
reqCtx, reqCancel := context.WithDeadline(
|
||||||
|
trace.ContextWithSpanContext(context.Background(), sc),
|
||||||
|
time.Now().Add(2*time.Hour),
|
||||||
|
)
|
||||||
|
defer reqCancel()
|
||||||
|
|
||||||
|
ctx := &api.Context{Context: reqCtx}
|
||||||
|
cancel := detachAsyncContext(ctx, func() {})
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
got := trace.SpanContextFromContext(ctx.Context)
|
||||||
|
if got.TraceID() != sc.TraceID() {
|
||||||
|
t.Errorf("expected the detached context to keep trace id %s, got %s", sc.TraceID(), got.TraceID())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetachAsyncContext_PreservesDeadline(t *testing.T) {
|
||||||
|
deadline := time.Now().Add(2 * time.Hour)
|
||||||
|
reqCtx, reqCancel := context.WithDeadline(context.Background(), deadline)
|
||||||
|
defer reqCancel()
|
||||||
|
|
||||||
|
ctx := &api.Context{Context: reqCtx}
|
||||||
|
cancel := detachAsyncContext(ctx, func() {})
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
got, ok := ctx.Deadline()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected the detached context to keep a deadline")
|
||||||
|
}
|
||||||
|
if !got.Equal(deadline) {
|
||||||
|
t.Errorf("expected deadline %v, got %v", deadline, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetachAsyncContext_SurvivesRequestCancellation(t *testing.T) {
|
||||||
|
reqCtx, reqCancel := context.WithDeadline(context.Background(), time.Now().Add(2*time.Hour))
|
||||||
|
|
||||||
|
ctx := &api.Context{Context: reqCtx}
|
||||||
|
cancel := detachAsyncContext(ctx, func() {})
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Cancelling the inbound request must not abort the detached context.
|
||||||
|
reqCancel()
|
||||||
|
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
t.Errorf("expected the detached context to survive request cancellation, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetachAsyncContext_CancelInvokesOriginal(t *testing.T) {
|
||||||
|
reqCtx, reqCancel := context.WithDeadline(context.Background(), time.Now().Add(time.Hour))
|
||||||
|
defer reqCancel()
|
||||||
|
|
||||||
|
called := 0
|
||||||
|
ctx := &api.Context{Context: reqCtx}
|
||||||
|
cancel := detachAsyncContext(ctx, func() { called++ })
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
if called != 1 {
|
||||||
|
t.Errorf("expected the original cancel to be invoked once, got %d", called)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -224,7 +224,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
|||||||
startTime: startTime,
|
startTime: startTime,
|
||||||
|
|
||||||
client: &retryablehttp.Client{
|
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,
|
RetryMax: w.maxRetry,
|
||||||
RetryWaitMin: w.retryMinWait,
|
RetryWaitMin: w.retryMinWait,
|
||||||
RetryWaitMax: w.retryMaxWait,
|
RetryWaitMax: w.retryMaxWait,
|
||||||
@@ -314,27 +314,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
|||||||
return c.NoContent(http.StatusNoContent)
|
return c.NoContent(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
if deadline, ok := ctx.Deadline(); ok {
|
cancel = detachAsyncContext(ctx, cancel)
|
||||||
// Create a new context derived from Background (detached from Request)
|
|
||||||
// but with the same deadline as the original context.
|
|
||||||
detachedCtx, detachedCancel := context.WithDeadline(context.Background(), deadline)
|
|
||||||
|
|
||||||
// Replace the embedded context in the api.Context struct.
|
|
||||||
// The modules downstream will now use this detached context.
|
|
||||||
ctx.Context = detachedCtx
|
|
||||||
|
|
||||||
// We must wrap the cancel function.
|
|
||||||
// 1. detachedCancel() cleans up our new detached context.
|
|
||||||
// 2. originalCancel() (captured from c.Get("cancel")) cleans up the working directory.
|
|
||||||
originalCancel := cancel
|
|
||||||
cancel = func() {
|
|
||||||
detachedCancel()
|
|
||||||
originalCancel()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback if no deadline was set (rare, as newContext enforces it).
|
|
||||||
ctx.Context = context.Background()
|
|
||||||
}
|
|
||||||
|
|
||||||
// As a webhook URL has been given, we handle the request in a
|
// As a webhook URL has been given, we handle the request in a
|
||||||
// goroutine and return immediately.
|
// goroutine and return immediately.
|
||||||
|
|||||||
86
pkg/modules/webhook/middleware_trace_test.go
Normal file
86
pkg/modules/webhook/middleware_trace_test.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package webhook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||||
|
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDetachAsyncContext_TraceContinuity asserts that an asynchronous webhook
|
||||||
|
// conversion stays in the inbound request's trace: the worker-root
|
||||||
|
// webhook.Async span and the downstream conversion span share the server
|
||||||
|
// span's trace id, and webhook.Async links back to the server span.
|
||||||
|
func TestDetachAsyncContext_TraceContinuity(t *testing.T) {
|
||||||
|
recorder := tracetest.NewSpanRecorder()
|
||||||
|
provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
|
||||||
|
|
||||||
|
previous := otel.GetTracerProvider()
|
||||||
|
otel.SetTracerProvider(provider)
|
||||||
|
t.Cleanup(func() { otel.SetTracerProvider(previous) })
|
||||||
|
|
||||||
|
serverCtx, serverSpan := provider.Tracer("test").Start(
|
||||||
|
context.Background(),
|
||||||
|
"POST /forms/chromium/convert/html",
|
||||||
|
trace.WithSpanKind(trace.SpanKindServer),
|
||||||
|
)
|
||||||
|
serverSpanCtx := serverSpan.SpanContext()
|
||||||
|
|
||||||
|
reqCtx, reqCancel := context.WithDeadline(serverCtx, time.Now().Add(time.Hour))
|
||||||
|
defer reqCancel()
|
||||||
|
|
||||||
|
ctx := &api.Context{Context: reqCtx}
|
||||||
|
cancel := detachAsyncContext(ctx, func() {})
|
||||||
|
|
||||||
|
// Simulate a downstream conversion span using the detached context, as the
|
||||||
|
// chromium/libreoffice engines would.
|
||||||
|
_, conversionSpan := gotenberg.Tracer().Start(ctx.Context, "chromium.Pdf", trace.WithSpanKind(trace.SpanKindClient))
|
||||||
|
conversionSpan.End()
|
||||||
|
|
||||||
|
// The server span ends when the synchronous handler returns, before the
|
||||||
|
// asynchronous worker finishes.
|
||||||
|
serverSpan.End()
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
var asyncSpan, conversion sdktrace.ReadOnlySpan
|
||||||
|
for _, s := range recorder.Ended() {
|
||||||
|
switch s.Name() {
|
||||||
|
case "webhook.Async":
|
||||||
|
asyncSpan = s
|
||||||
|
case "chromium.Pdf":
|
||||||
|
conversion = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if asyncSpan == nil {
|
||||||
|
t.Fatal("expected a webhook.Async span to be recorded")
|
||||||
|
}
|
||||||
|
if conversion == nil {
|
||||||
|
t.Fatal("expected a chromium.Pdf span to be recorded")
|
||||||
|
}
|
||||||
|
|
||||||
|
if conversion.SpanContext().TraceID() != serverSpanCtx.TraceID() {
|
||||||
|
t.Errorf("conversion span trace id = %s, want %s", conversion.SpanContext().TraceID(), serverSpanCtx.TraceID())
|
||||||
|
}
|
||||||
|
if asyncSpan.SpanContext().TraceID() != serverSpanCtx.TraceID() {
|
||||||
|
t.Errorf("webhook.Async trace id = %s, want %s", asyncSpan.SpanContext().TraceID(), serverSpanCtx.TraceID())
|
||||||
|
}
|
||||||
|
if conversion.Parent().SpanID() != asyncSpan.SpanContext().SpanID() {
|
||||||
|
t.Errorf("conversion span parent = %s, want webhook.Async %s", conversion.Parent().SpanID(), asyncSpan.SpanContext().SpanID())
|
||||||
|
}
|
||||||
|
|
||||||
|
links := asyncSpan.Links()
|
||||||
|
if len(links) != 1 {
|
||||||
|
t.Fatalf("expected webhook.Async to have 1 link, got %d", len(links))
|
||||||
|
}
|
||||||
|
if links[0].SpanContext.SpanID() != serverSpanCtx.SpanID() {
|
||||||
|
t.Errorf("webhook.Async link span id = %s, want %s", links[0].SpanContext.SpanID(), serverSpanCtx.SpanID())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,19 +18,20 @@ func init() {
|
|||||||
// Webhook is a module that provides a middleware for uploading output files
|
// Webhook is a module that provides a middleware for uploading output files
|
||||||
// to any destinations in an asynchronous fashion.
|
// to any destinations in an asynchronous fashion.
|
||||||
type Webhook struct {
|
type Webhook struct {
|
||||||
enableSyncMode bool
|
enableSyncMode bool
|
||||||
allowList []*regexp2.Regexp
|
allowList []*regexp2.Regexp
|
||||||
denyList []*regexp2.Regexp
|
denyList []*regexp2.Regexp
|
||||||
errorAllowList []*regexp2.Regexp
|
errorAllowList []*regexp2.Regexp
|
||||||
errorDenyList []*regexp2.Regexp
|
errorDenyList []*regexp2.Regexp
|
||||||
denyPrivateIPs bool
|
denyPrivateIPs bool
|
||||||
denyPublicIPs bool
|
denyPublicIPs bool
|
||||||
maxRetry int
|
enableEnvironmentProxy bool
|
||||||
retryMinWait time.Duration
|
maxRetry int
|
||||||
retryMaxWait time.Duration
|
retryMinWait time.Duration
|
||||||
clientTimeout time.Duration
|
retryMaxWait time.Duration
|
||||||
asyncCount atomic.Int64
|
clientTimeout time.Duration
|
||||||
disable bool
|
asyncCount atomic.Int64
|
||||||
|
disable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Descriptor returns an [Webhook]'s module descriptor.
|
// 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.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-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-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")
|
fs.Int("webhook-max-retry", 4, "Set the maximum number of retries for the webhook feature")
|
||||||
|
|
||||||
// Deprecated flags.
|
// 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.errorDenyList = flags.MustDeprecatedRegexpSlice("webhook-error-deny-list", "webhook-deny-list")
|
||||||
w.denyPrivateIPs = flags.MustBool("webhook-deny-private-ips")
|
w.denyPrivateIPs = flags.MustBool("webhook-deny-private-ips")
|
||||||
w.denyPublicIPs = flags.MustBool("webhook-deny-public-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.maxRetry = flags.MustInt("webhook-max-retry")
|
||||||
w.retryMinWait = flags.MustDuration("webhook-retry-min-wait")
|
w.retryMinWait = flags.MustDuration("webhook-retry-min-wait")
|
||||||
w.retryMaxWait = flags.MustDuration("webhook-retry-max-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 have <N> page(s)`
|
||||||
- `the "<name>" PDF (should|should NOT) be set to landscape orientation`
|
- `the "<name>" PDF (should|should NOT) be set to landscape orientation`
|
||||||
- `the "<name>" PDF (should|should NOT) have the following content at page <N>:` (docstring)
|
- `the "<name>" PDF (should|should NOT) have the following content at page <N>:` (docstring)
|
||||||
|
- `the "<name>" PDF (should|should NOT) have content matching "<regexp>" at page <N>`
|
||||||
- `the (response|webhook request) PDF(s) should be valid "<standard>" with a tolerance of <N> failed rule(s)` (standards: `PDF/A-1b`, `PDF/A-2b`, `PDF/A-3b`, `PDF/UA-1`, `PDF/UA-2`)
|
- `the (response|webhook request) PDF(s) should be valid "<standard>" with a tolerance of <N> failed rule(s)` (standards: `PDF/A-1b`, `PDF/A-2b`, `PDF/A-3b`, `PDF/UA-1`, `PDF/UA-2`)
|
||||||
- `the (response|webhook request) PDF(s) (should|should NOT) be flatten`
|
- `the (response|webhook request) PDF(s) (should|should NOT) be flatten`
|
||||||
- `the (response|webhook request) PDF(s) (should|should NOT) be encrypted`
|
- `the (response|webhook request) PDF(s) (should|should NOT) be encrypted`
|
||||||
|
- `the (response|webhook request) PDF(s) (should|should NOT) allow "<action>"` (actions: `printing`, `copying`, `modifying`, `annotating`)
|
||||||
- `the (response|webhook request) PDF(s) (should|should NOT) have the "<filename>" file embedded`
|
- `the (response|webhook request) PDF(s) (should|should NOT) have the "<filename>" file embedded`
|
||||||
- `the "<name>" PDF should have <N> image(s)`
|
- `the "<name>" PDF should have <N> image(s)`
|
||||||
- `the Gotenberg container (should|should NOT) log the following entries:` (table of log substrings)
|
- `the Gotenberg container (should|should NOT) log the following entries:` (table of log substrings)
|
||||||
|
|||||||
@@ -548,6 +548,25 @@ Feature: /forms/chromium/convert/html
|
|||||||
Error: Exception 2
|
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)
|
Scenario: POST /forms/chromium/convert/html (Bad Request)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
@@ -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 header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
Then the response body should match string:
|
||||||
"""
|
"""
|
||||||
At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
|
The requested split mode is not supported, or no PDF engine could process it. Valid modes: 'intervals', 'pages'.
|
||||||
"""
|
"""
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
| files | testdata/page-1-html/index.html | file |
|
| files | testdata/page-1-html/index.html | file |
|
||||||
@@ -744,7 +763,7 @@ Feature: /forms/chromium/convert/html
|
|||||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
Then the response body should match string:
|
||||||
"""
|
"""
|
||||||
At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
|
The requested PDF format is not supported, or no PDF engine could apply it. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.
|
||||||
"""
|
"""
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
| files | testdata/page-1-html/index.html | file |
|
| files | testdata/page-1-html/index.html | file |
|
||||||
|
|||||||
@@ -652,7 +652,7 @@ Feature: /forms/chromium/convert/markdown
|
|||||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
Then the response body should match string:
|
||||||
"""
|
"""
|
||||||
At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
|
The requested split mode is not supported, or no PDF engine could process it. Valid modes: 'intervals', 'pages'.
|
||||||
"""
|
"""
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
|
||||||
| files | testdata/page-1-markdown/index.html | file |
|
| files | testdata/page-1-markdown/index.html | file |
|
||||||
@@ -662,7 +662,7 @@ Feature: /forms/chromium/convert/markdown
|
|||||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
Then the response body should match string:
|
||||||
"""
|
"""
|
||||||
At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
|
The requested PDF format is not supported, or no PDF engine could apply it. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.
|
||||||
"""
|
"""
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
|
||||||
| files | testdata/page-1-markdown/index.html | file |
|
| files | testdata/page-1-markdown/index.html | file |
|
||||||
|
|||||||
@@ -815,7 +815,7 @@ Feature: /forms/chromium/convert/url
|
|||||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
Then the response body should match string:
|
||||||
"""
|
"""
|
||||||
At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues
|
The requested split mode is not supported, or no PDF engine could process it. Valid modes: 'intervals', 'pages'.
|
||||||
"""
|
"""
|
||||||
Given I have a static server
|
Given I have a static server
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||||
@@ -825,7 +825,7 @@ Feature: /forms/chromium/convert/url
|
|||||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
Then the response body should match string:
|
||||||
"""
|
"""
|
||||||
At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues
|
The requested PDF format is not supported, or no PDF engine could apply it. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA.
|
||||||
"""
|
"""
|
||||||
Given I have a static server
|
Given I have a static server
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||||
|
|||||||
@@ -72,6 +72,15 @@ Feature: /forms/chromium/screenshot/html
|
|||||||
Then the response status code should be 200
|
Then the response status code should be 200
|
||||||
Then the response header "Content-Type" should be "image/png"
|
Then the response header "Content-Type" should be "image/png"
|
||||||
|
|
||||||
|
Scenario: POST /forms/chromium/screenshot/html (Device Scale Factor)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/page-1-html/index.html | file |
|
||||||
|
| deviceScaleFactor | 1.0 | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "image/png"
|
||||||
|
|
||||||
Scenario: POST /forms/chromium/screenshot/html (Omit Background)
|
Scenario: POST /forms/chromium/screenshot/html (Omit Background)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user