mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-10-14 16:23:18 +01:00
Compare commits
159 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
430ed38cc5 | ||
|
|
623e93bf3e | ||
|
|
fe4fb9416d | ||
|
|
fb7536a526 | ||
|
|
b16ce08da7 | ||
|
|
fcfd590169 | ||
|
|
b39b8c76aa | ||
|
|
06ed58b6e7 | ||
|
|
34b7b4845e | ||
|
|
ab3832d9e5 | ||
|
|
cddaa0fa57 | ||
|
|
ade6a327a4 | ||
|
|
35ddf81812 | ||
|
|
1891a9ea68 | ||
|
|
e1e0a80883 | ||
|
|
c21ceacd4b | ||
|
|
ac825a2c03 | ||
|
|
2c9fa6b6ed | ||
|
|
ca8b45cd3a | ||
|
|
40cf48442f | ||
|
|
df3bac99ed | ||
|
|
17868b8c02 | ||
|
|
78284df590 | ||
|
|
8f415186d5 | ||
|
|
f675f78f77 | ||
|
|
83b01c2baa | ||
|
|
9a46fdd681 | ||
|
|
4de9b0f68b | ||
|
|
201e80b9d7 | ||
|
|
86a013b664 | ||
|
|
9c5acd7418 | ||
|
|
e29b7cb4f5 | ||
|
|
23d59f3133 | ||
|
|
8a0de7d5d7 | ||
|
|
3c691cbebc | ||
|
|
0e83f737b4 | ||
|
|
57b048c611 | ||
|
|
d79e174c6f | ||
|
|
88ddaed09b | ||
|
|
8944db131c | ||
|
|
676570074a | ||
|
|
11179cb271 | ||
|
|
c2a85f92d0 | ||
|
|
7dbff18e65 | ||
|
|
923e5f71eb | ||
|
|
334f859d95 | ||
|
|
0819514b7b | ||
|
|
c636a52666 | ||
|
|
0b16b0ab34 | ||
|
|
c0f487e333 | ||
|
|
3de6932279 | ||
|
|
e9a67132ec | ||
|
|
41b33fd6ad | ||
|
|
2521485bf8 | ||
|
|
65e5699b71 | ||
|
|
b3c06fb8ae | ||
|
|
f90684e165 | ||
|
|
90e614afa4 | ||
|
|
6a087cb1f7 | ||
|
|
38db892552 | ||
|
|
ca9603bdf4 | ||
|
|
0c1e82c885 | ||
|
|
1303e0ebc9 | ||
|
|
cc1341c3cf | ||
|
|
1ac1d9887e | ||
|
|
7a730cfdc2 | ||
|
|
b213f2ffed | ||
|
|
8d1eeaa73a | ||
|
|
2f6818d3df | ||
|
|
38f6e466d4 | ||
|
|
e39840d5cf | ||
|
|
9b48d3f84e | ||
|
|
05bde96334 | ||
|
|
8b2c15d5de | ||
|
|
8d327a5196 | ||
|
|
dc61c3631e | ||
|
|
dc7c68152c | ||
|
|
357c3b4a59 | ||
|
|
91f2587fef | ||
|
|
9ec7be4687 | ||
|
|
de7f335791 | ||
|
|
815f586315 | ||
|
|
b71df026f6 | ||
|
|
8d29638b74 | ||
|
|
63c9a36599 | ||
|
|
31fa392db2 | ||
|
|
bb0b874d16 | ||
|
|
60f5a7b996 | ||
|
|
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 |
@@ -47,12 +47,24 @@ body:multipart-form {
|
|||||||
~splitUnify: false
|
~splitUnify: false
|
||||||
~pdfa: PDF/A-1b
|
~pdfa: PDF/A-1b
|
||||||
~pdfua: true
|
~pdfua: true
|
||||||
|
~optimizeImages: false
|
||||||
|
~imageQuality: 80
|
||||||
~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:
|
||||||
|
|||||||
@@ -48,12 +48,24 @@ body:multipart-form {
|
|||||||
~splitUnify: false
|
~splitUnify: false
|
||||||
~pdfa: PDF/A-1b
|
~pdfa: PDF/A-1b
|
||||||
~pdfua: true
|
~pdfua: true
|
||||||
|
~optimizeImages: false
|
||||||
|
~imageQuality: 80
|
||||||
~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:
|
||||||
|
|||||||
@@ -47,12 +47,24 @@ body:multipart-form {
|
|||||||
~splitUnify: false
|
~splitUnify: false
|
||||||
~pdfa: PDF/A-1b
|
~pdfa: PDF/A-1b
|
||||||
~pdfua: true
|
~pdfua: true
|
||||||
|
~optimizeImages: false
|
||||||
|
~imageQuality: 80
|
||||||
~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,7 @@ body:multipart-form {
|
|||||||
~width: 800
|
~width: 800
|
||||||
~height: 600
|
~height: 600
|
||||||
~clip: false
|
~clip: false
|
||||||
|
~selector:
|
||||||
~format: png
|
~format: png
|
||||||
~quality: 100
|
~quality: 100
|
||||||
~optimizeForSpeed: false
|
~optimizeForSpeed: false
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ body:multipart-form {
|
|||||||
~width: 800
|
~width: 800
|
||||||
~height: 600
|
~height: 600
|
||||||
~clip: false
|
~clip: false
|
||||||
|
~selector:
|
||||||
~format: png
|
~format: png
|
||||||
~quality: 100
|
~quality: 100
|
||||||
~optimizeForSpeed: false
|
~optimizeForSpeed: false
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ body:multipart-form {
|
|||||||
~width: 800
|
~width: 800
|
||||||
~height: 600
|
~height: 600
|
||||||
~clip: false
|
~clip: false
|
||||||
|
~selector:
|
||||||
~format: png
|
~format: png
|
||||||
~quality: 100
|
~quality: 100
|
||||||
~optimizeForSpeed: false
|
~optimizeForSpeed: false
|
||||||
|
|||||||
@@ -64,12 +64,24 @@ body:multipart-form {
|
|||||||
~splitUnify: false
|
~splitUnify: false
|
||||||
~pdfa: PDF/A-1b
|
~pdfa: PDF/A-1b
|
||||||
~pdfua: true
|
~pdfua: true
|
||||||
|
~optimizeImages: false
|
||||||
|
~imageQuality: 80
|
||||||
~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"}
|
||||||
|
}
|
||||||
@@ -15,12 +15,21 @@ body:multipart-form {
|
|||||||
files: @file(../../test/integration/testdata/page_2.pdf)
|
files: @file(../../test/integration/testdata/page_2.pdf)
|
||||||
~flatten: false
|
~flatten: false
|
||||||
~autoIndexBookmarks: false
|
~autoIndexBookmarks: false
|
||||||
|
~titleBookmarks: false
|
||||||
~pdfa: PDF/A-1b
|
~pdfa: PDF/A-1b
|
||||||
~pdfua: true
|
~pdfua: true
|
||||||
|
~optimizeImages: false
|
||||||
|
~imageQuality: 80
|
||||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||||
~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 +40,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 {
|
||||||
|
|||||||
26
.bruno/PDF Engines/Optimize/Optimize PDF.bru
Normal file
26
.bruno/PDF Engines/Optimize/Optimize PDF.bru
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
meta {
|
||||||
|
name: Optimize PDF
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{baseUrl}}/forms/pdfengines/optimize
|
||||||
|
body: multipartForm
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
body:multipart-form {
|
||||||
|
files: @file(../../test/integration/testdata/page_1.pdf)
|
||||||
|
~imageQuality: 80
|
||||||
|
}
|
||||||
|
|
||||||
|
headers {
|
||||||
|
~Gotenberg-Output-Filename: optimized
|
||||||
|
~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"}
|
||||||
|
}
|
||||||
@@ -18,9 +18,17 @@ body:multipart-form {
|
|||||||
~flatten: false
|
~flatten: false
|
||||||
~pdfa: PDF/A-1b
|
~pdfa: PDF/A-1b
|
||||||
~pdfua: true
|
~pdfua: true
|
||||||
|
~optimizeImages: false
|
||||||
|
~imageQuality: 80
|
||||||
~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 +39,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/actions/build-test-push/action.yml
vendored
6
.github/actions/build-test-push/action.yml
vendored
@@ -83,12 +83,16 @@ runs:
|
|||||||
INPUT_PLATFORM: ${{ inputs.platform }}
|
INPUT_PLATFORM: ${{ inputs.platform }}
|
||||||
INPUT_ALTERNATE_REPOSITORY: ${{ inputs.alternate_repository }}
|
INPUT_ALTERNATE_REPOSITORY: ${{ inputs.alternate_repository }}
|
||||||
INPUT_DRY_RUN: ${{ inputs.dry_run }}
|
INPUT_DRY_RUN: ${{ inputs.dry_run }}
|
||||||
|
# Exporting the build cache needs a registry login. Forks run without
|
||||||
|
# credentials, so they import the cache but never export it.
|
||||||
|
INPUT_CACHE_WRITABLE: ${{ inputs.docker_hub_username != '' }}
|
||||||
run: |
|
run: |
|
||||||
.github/actions/build-test-push/build.sh \
|
.github/actions/build-test-push/build.sh \
|
||||||
--version "$INPUT_VERSION" \
|
--version "$INPUT_VERSION" \
|
||||||
--platform "$INPUT_PLATFORM" \
|
--platform "$INPUT_PLATFORM" \
|
||||||
--alternate-repository "$INPUT_ALTERNATE_REPOSITORY" \
|
--alternate-repository "$INPUT_ALTERNATE_REPOSITORY" \
|
||||||
--dry-run "$INPUT_DRY_RUN"
|
--dry-run "$INPUT_DRY_RUN" \
|
||||||
|
--cache-writable "$INPUT_CACHE_WRITABLE"
|
||||||
|
|
||||||
- name: Run integration tests
|
- name: Run integration tests
|
||||||
if: inputs.skip_integrations_tests != 'true'
|
if: inputs.skip_integrations_tests != 'true'
|
||||||
|
|||||||
75
.github/actions/build-test-push/build.sh
vendored
75
.github/actions/build-test-push/build.sh
vendored
@@ -12,6 +12,7 @@ version=""
|
|||||||
platform=""
|
platform=""
|
||||||
alternate_repository=""
|
alternate_repository=""
|
||||||
dry_run=""
|
dry_run=""
|
||||||
|
cache_writable=""
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
@@ -31,6 +32,10 @@ while [[ $# -gt 0 ]]; do
|
|||||||
dry_run="$2"
|
dry_run="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--cache-writable)
|
||||||
|
cache_writable="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option $1"
|
echo "Unknown option $1"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -44,11 +49,41 @@ echo
|
|||||||
echo "Gotenberg version: $version"
|
echo "Gotenberg version: $version"
|
||||||
echo "Target platform: $platform"
|
echo "Target platform: $platform"
|
||||||
|
|
||||||
|
# The build cache lives under the canonical repository, captured before the
|
||||||
|
# alternate-repository override below. Pull requests build into "snapshot", so
|
||||||
|
# deriving the cache ref after the override would give them a cache namespace
|
||||||
|
# of their own and they would never import what main published, which is the
|
||||||
|
# population that benefits most.
|
||||||
|
cache_image="$DOCKER_REGISTRY/$DOCKER_REPOSITORY"
|
||||||
|
|
||||||
|
# Layers are per-architecture, so each platform keeps its own cache manifest.
|
||||||
|
cache_platform="${platform//\//-}"
|
||||||
|
|
||||||
|
# Layers running "apt-get upgrade" install whatever versions are current at
|
||||||
|
# build time, and the packages are deliberately not pinned. A persistent cache
|
||||||
|
# would turn those into hits and freeze security patches into a published
|
||||||
|
# image until debian:13-slim itself changes digest. Keying them on the ISO week
|
||||||
|
# bounds that staleness to seven days while leaving every build within a week
|
||||||
|
# free to reuse the cache.
|
||||||
|
apt_snapshot="$(date -u +%G-W%V)"
|
||||||
|
|
||||||
|
# Only a build that is not redirected to an alternate repository writes the
|
||||||
|
# cache, so a pull request cannot make its own state the baseline for main.
|
||||||
|
# Reading stays enabled everywhere, including forks, since the cache ref is
|
||||||
|
# public and needs no credentials.
|
||||||
|
cache_to_enabled="false"
|
||||||
|
if [ "$cache_writable" = "true" ] && [ -z "$alternate_repository" ]; then
|
||||||
|
cache_to_enabled="true"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -n "$alternate_repository" ]; then
|
if [ -n "$alternate_repository" ]; then
|
||||||
DOCKER_REPOSITORY=$alternate_repository
|
DOCKER_REPOSITORY=$alternate_repository
|
||||||
echo "⚠️ Using $alternate_repository for DOCKER_REPOSITORY"
|
echo "⚠️ Using $alternate_repository for DOCKER_REPOSITORY"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "Build cache: $cache_image:buildcache-<target>-$cache_platform (write: $cache_to_enabled)"
|
||||||
|
echo "APT snapshot: $apt_snapshot"
|
||||||
|
|
||||||
if [ "$dry_run" = "true" ]; then
|
if [ "$dry_run" = "true" ]; then
|
||||||
echo "🚧 Dry run"
|
echo "🚧 Dry run"
|
||||||
fi
|
fi
|
||||||
@@ -189,12 +224,36 @@ join() {
|
|||||||
echo "$*"
|
echo "$*"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# cache_flags echoes the buildx cache arguments for a build target. Each target
|
||||||
|
# keeps its own manifest so that the Chromium and LibreOffice variants, which
|
||||||
|
# branch from common-stage rather than from each other, do not overwrite one
|
||||||
|
# another's entry.
|
||||||
|
#
|
||||||
|
# mode=max exports intermediate stages too, not just the final layers, which is
|
||||||
|
# what makes the expensive apt and jlink stages reusable. type=registry, not
|
||||||
|
# type=gha: the GitHub Actions cache is capped at 10 GB per repository and is
|
||||||
|
# already carrying the Go and golangci-lint caches that the lint and test jobs
|
||||||
|
# depend on. Multi-GB image layers across five platforms would evict them.
|
||||||
|
cache_flags() {
|
||||||
|
local target="$1"
|
||||||
|
local ref="$cache_image:buildcache-$target-$cache_platform"
|
||||||
|
local flags="--cache-from type=registry,ref=$ref"
|
||||||
|
|
||||||
|
if [ "$cache_to_enabled" = "true" ]; then
|
||||||
|
flags="$flags --cache-to type=registry,ref=$ref,mode=max"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$flags"
|
||||||
|
}
|
||||||
|
|
||||||
no_arch_tag="$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version"
|
no_arch_tag="$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version"
|
||||||
|
|
||||||
# Full variant.
|
# Full variant.
|
||||||
cmd="docker buildx build \
|
cmd="docker buildx build \
|
||||||
--target gotenberg \
|
--target gotenberg \
|
||||||
--build-arg GOTENBERG_VERSION=$version \
|
--build-arg GOTENBERG_VERSION=$version \
|
||||||
|
--build-arg APT_SNAPSHOT=$apt_snapshot \
|
||||||
|
$(cache_flags gotenberg) \
|
||||||
--platform $platform \
|
--platform $platform \
|
||||||
--load \
|
--load \
|
||||||
${tags_flags[*]} \
|
${tags_flags[*]} \
|
||||||
@@ -207,6 +266,8 @@ run_cmd "$cmd"
|
|||||||
cmd="docker buildx build \
|
cmd="docker buildx build \
|
||||||
--target gotenberg-chromium \
|
--target gotenberg-chromium \
|
||||||
--build-arg GOTENBERG_VERSION=$version \
|
--build-arg GOTENBERG_VERSION=$version \
|
||||||
|
--build-arg APT_SNAPSHOT=$apt_snapshot \
|
||||||
|
$(cache_flags gotenberg-chromium) \
|
||||||
--platform $platform \
|
--platform $platform \
|
||||||
--load \
|
--load \
|
||||||
${tags_chromium_flags[*]} \
|
${tags_chromium_flags[*]} \
|
||||||
@@ -218,6 +279,8 @@ run_cmd "$cmd"
|
|||||||
cmd="docker buildx build \
|
cmd="docker buildx build \
|
||||||
--target gotenberg-libreoffice \
|
--target gotenberg-libreoffice \
|
||||||
--build-arg GOTENBERG_VERSION=$version \
|
--build-arg GOTENBERG_VERSION=$version \
|
||||||
|
--build-arg APT_SNAPSHOT=$apt_snapshot \
|
||||||
|
$(cache_flags gotenberg-libreoffice) \
|
||||||
--platform $platform \
|
--platform $platform \
|
||||||
--load \
|
--load \
|
||||||
${tags_libreoffice_flags[*]} \
|
${tags_libreoffice_flags[*]} \
|
||||||
@@ -230,6 +293,8 @@ if [ "$platform" = "linux/amd64" ]; then
|
|||||||
cmd="docker buildx build \
|
cmd="docker buildx build \
|
||||||
--target gotenberg-cloudrun \
|
--target gotenberg-cloudrun \
|
||||||
--build-arg GOTENBERG_VERSION=$version \
|
--build-arg GOTENBERG_VERSION=$version \
|
||||||
|
--build-arg APT_SNAPSHOT=$apt_snapshot \
|
||||||
|
$(cache_flags gotenberg-cloudrun) \
|
||||||
--platform $platform \
|
--platform $platform \
|
||||||
--load \
|
--load \
|
||||||
${tags_cloud_run_flags[*]} \
|
${tags_cloud_run_flags[*]} \
|
||||||
@@ -240,6 +305,8 @@ if [ "$platform" = "linux/amd64" ]; then
|
|||||||
cmd="docker buildx build \
|
cmd="docker buildx build \
|
||||||
--target gotenberg-cloudrun-chromium \
|
--target gotenberg-cloudrun-chromium \
|
||||||
--build-arg GOTENBERG_VERSION=$version \
|
--build-arg GOTENBERG_VERSION=$version \
|
||||||
|
--build-arg APT_SNAPSHOT=$apt_snapshot \
|
||||||
|
$(cache_flags gotenberg-cloudrun-chromium) \
|
||||||
--platform $platform \
|
--platform $platform \
|
||||||
--load \
|
--load \
|
||||||
${tags_cloud_run_chromium_flags[*]} \
|
${tags_cloud_run_chromium_flags[*]} \
|
||||||
@@ -250,6 +317,8 @@ if [ "$platform" = "linux/amd64" ]; then
|
|||||||
cmd="docker buildx build \
|
cmd="docker buildx build \
|
||||||
--target gotenberg-cloudrun-libreoffice \
|
--target gotenberg-cloudrun-libreoffice \
|
||||||
--build-arg GOTENBERG_VERSION=$version \
|
--build-arg GOTENBERG_VERSION=$version \
|
||||||
|
--build-arg APT_SNAPSHOT=$apt_snapshot \
|
||||||
|
$(cache_flags gotenberg-cloudrun-libreoffice) \
|
||||||
--platform $platform \
|
--platform $platform \
|
||||||
--load \
|
--load \
|
||||||
${tags_cloud_run_libreoffice_flags[*]} \
|
${tags_cloud_run_libreoffice_flags[*]} \
|
||||||
@@ -263,6 +332,8 @@ if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then
|
|||||||
cmd="docker buildx build \
|
cmd="docker buildx build \
|
||||||
--target gotenberg-aws-lambda \
|
--target gotenberg-aws-lambda \
|
||||||
--build-arg GOTENBERG_VERSION=$version \
|
--build-arg GOTENBERG_VERSION=$version \
|
||||||
|
--build-arg APT_SNAPSHOT=$apt_snapshot \
|
||||||
|
$(cache_flags gotenberg-aws-lambda) \
|
||||||
--platform $platform \
|
--platform $platform \
|
||||||
--load \
|
--load \
|
||||||
${tags_aws_lambda_flags[*]} \
|
${tags_aws_lambda_flags[*]} \
|
||||||
@@ -273,6 +344,8 @@ if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then
|
|||||||
cmd="docker buildx build \
|
cmd="docker buildx build \
|
||||||
--target gotenberg-aws-lambda-chromium \
|
--target gotenberg-aws-lambda-chromium \
|
||||||
--build-arg GOTENBERG_VERSION=$version \
|
--build-arg GOTENBERG_VERSION=$version \
|
||||||
|
--build-arg APT_SNAPSHOT=$apt_snapshot \
|
||||||
|
$(cache_flags gotenberg-aws-lambda-chromium) \
|
||||||
--platform $platform \
|
--platform $platform \
|
||||||
--load \
|
--load \
|
||||||
${tags_aws_lambda_chromium_flags[*]} \
|
${tags_aws_lambda_chromium_flags[*]} \
|
||||||
@@ -283,6 +356,8 @@ if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then
|
|||||||
cmd="docker buildx build \
|
cmd="docker buildx build \
|
||||||
--target gotenberg-aws-lambda-libreoffice \
|
--target gotenberg-aws-lambda-libreoffice \
|
||||||
--build-arg GOTENBERG_VERSION=$version \
|
--build-arg GOTENBERG_VERSION=$version \
|
||||||
|
--build-arg APT_SNAPSHOT=$apt_snapshot \
|
||||||
|
$(cache_flags gotenberg-aws-lambda-libreoffice) \
|
||||||
--platform $platform \
|
--platform $platform \
|
||||||
--load \
|
--load \
|
||||||
${tags_aws_lambda_libreoffice_flags[*]} \
|
${tags_aws_lambda_libreoffice_flags[*]} \
|
||||||
|
|||||||
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
|
||||||
|
|||||||
38
.github/workflows/continuous-integration.yml
vendored
38
.github/workflows/continuous-integration.yml
vendored
@@ -21,27 +21,27 @@ 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
|
||||||
|
|
||||||
- name: Run linters
|
- name: Run linters
|
||||||
uses: golangci/golangci-lint-action@v9
|
uses: golangci/golangci-lint-action@v9
|
||||||
with:
|
with:
|
||||||
version: v2.10.1
|
version: v2.13.2
|
||||||
|
|
||||||
lint-prettier:
|
lint-prettier:
|
||||||
name: Lint non-Golang codebase
|
name: Lint non-Golang codebase
|
||||||
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.
|
||||||
@@ -112,20 +121,28 @@ Enforced by `gci`: standard library, then third-party, then `github.com/gotenber
|
|||||||
Every exported type and function has a Godoc comment starting with its identifier name:
|
Every exported type and function has a Godoc comment starting with its identifier name:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// Violation records a single rule violation with context.
|
// OutboundDecision is the result of validating an outbound URL via
|
||||||
type Violation struct { ... }
|
// [DecideOutbound]. ...
|
||||||
|
type OutboundDecision struct { ... }
|
||||||
|
|
||||||
// ValidatePDFA audits the document against a PDF/A profile.
|
// DialPinned dials each addr in turn until one connects, returning the
|
||||||
func ValidatePDFA(ctx context.Context, ...) ([]error, error)
|
// first successful connection or the last error. ...
|
||||||
|
func DialPinned(ctx context.Context, network string, addrs []netip.Addr, port string) (net.Conn, error)
|
||||||
```
|
```
|
||||||
|
|
||||||
Each package should have a `doc.go` with a `// Package foo ...` comment.
|
Each package should have a `doc.go` with a `// Package foo ...` comment:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Package api manages a LibreOffice instance via the UNO API.
|
||||||
|
package api
|
||||||
|
```
|
||||||
|
|
||||||
Reference identifiers with `[Name]` brackets for pkg.go.dev linking:
|
Reference identifiers with `[Name]` brackets for pkg.go.dev linking:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// ValidatePDFA returns violations as []error where each element
|
// Callers pass the Pinned slice from [OutboundDecision] so that the dial
|
||||||
// is a [Violation] value. See [Rule] for the structured fields.
|
// targets exactly the IPs that [DecideOutbound] resolved, preventing DNS
|
||||||
|
// rebinding between validation and connect.
|
||||||
```
|
```
|
||||||
|
|
||||||
### Code comments
|
### Code comments
|
||||||
|
|||||||
51
Makefile
51
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=
|
||||||
@@ -26,9 +28,21 @@ API_CORRELATION_ID_HEADER=Gotenberg-Trace
|
|||||||
API_ENABLE_BASIC_AUTH=false
|
API_ENABLE_BASIC_AUTH=false
|
||||||
GOTENBERG_API_BASIC_AUTH_USERNAME=
|
GOTENBERG_API_BASIC_AUTH_USERNAME=
|
||||||
GOTENBERG_API_BASIC_AUTH_PASSWORD=
|
GOTENBERG_API_BASIC_AUTH_PASSWORD=
|
||||||
|
API_ENABLE_OIDC_AUTH=false
|
||||||
|
API_OIDC_ISSUER=
|
||||||
|
API_OIDC_AUDIENCE=
|
||||||
|
API_OIDC_JWKS_URL=
|
||||||
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)
|
# Empty, like the flag default since 8.32.0. A textual deny-list cannot
|
||||||
|
# enumerate every way to write a private address, so *_DENY_PRIVATE_IPS is the
|
||||||
|
# control to reach for. Left false here so local testing can reach the host.
|
||||||
|
API_DOWNLOAD_FROM_DENY_LIST=
|
||||||
|
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_DOWNLOAD_FROM_MAX_CONCURRENCY=10
|
||||||
|
API_DOWNLOAD_FROM_MAX_ENTRIES=0
|
||||||
API_DISABLE_DOWNLOAD_FROM=false
|
API_DISABLE_DOWNLOAD_FROM=false
|
||||||
API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=true
|
API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=true
|
||||||
API_DISABLE_ROOT_ROUTE_TELEMETRY=true
|
API_DISABLE_ROOT_ROUTE_TELEMETRY=true
|
||||||
@@ -47,8 +61,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,16 +77,22 @@ 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_MAX_CONCURRENCY=1
|
||||||
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
|
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
|
||||||
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
|
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
|
||||||
PDFENGINES_FLATTEN_ENGINES=qpdf
|
PDFENGINES_FLATTEN_ENGINES=qpdf
|
||||||
PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
|
PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
|
||||||
|
PDFENGINES_OPTIMIZE_IMAGES_ENGINES=pdfcpu
|
||||||
PDFENGINES_READ_METADATA_ENGINES=exiftool
|
PDFENGINES_READ_METADATA_ENGINES=exiftool
|
||||||
PDFENGINES_WRITE_METADATA_ENGINES=exiftool
|
PDFENGINES_WRITE_METADATA_ENGINES=exiftool
|
||||||
PDFENGINES_READ_BOOKMARKS_ENGINES=pdfcpu
|
PDFENGINES_READ_BOOKMARKS_ENGINES=pdfcpu
|
||||||
@@ -78,8 +101,9 @@ PDFENGINES_WATERMARK_ENGINES=pdfcpu,pdftk
|
|||||||
PDFENGINES_STAMP_ENGINES=pdfcpu,pdftk
|
PDFENGINES_STAMP_ENGINES=pdfcpu,pdftk
|
||||||
PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
|
PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
|
||||||
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
|
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
|
||||||
PDFENGINES_EMBED_ENGINES=qpdf,pdfcpu
|
PDFENGINES_EMBED_ENGINES=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
|
||||||
@@ -94,7 +118,11 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
|||||||
OTEL_EXPORTER_OTLP_INSECURE=true
|
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)
|
# See the note on API_DOWNLOAD_FROM_DENY_LIST.
|
||||||
|
WEBHOOK_DENY_LIST=
|
||||||
|
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
|
||||||
@@ -131,10 +159,12 @@ NO_CONCURRENCY=false
|
|||||||
# chromium-screenshot-html
|
# chromium-screenshot-html
|
||||||
# chromium-screenshot-markdown
|
# chromium-screenshot-markdown
|
||||||
# chromium-screenshot-url
|
# chromium-screenshot-url
|
||||||
|
# chromium-ssrf
|
||||||
# debug
|
# debug
|
||||||
# health
|
# health
|
||||||
# libreoffice
|
# libreoffice
|
||||||
# libreoffice-convert
|
# libreoffice-convert
|
||||||
|
# libreoffice-ssrf
|
||||||
# output-filename
|
# output-filename
|
||||||
# pdfengines
|
# pdfengines
|
||||||
# pdfengines-convert
|
# pdfengines-convert
|
||||||
@@ -144,6 +174,8 @@ NO_CONCURRENCY=false
|
|||||||
# encrypt
|
# encrypt
|
||||||
# pdfengines-flatten
|
# pdfengines-flatten
|
||||||
# flatten
|
# flatten
|
||||||
|
# pdfengines-optimize
|
||||||
|
# optimize
|
||||||
# pdfengines-merge
|
# pdfengines-merge
|
||||||
# merge
|
# merge
|
||||||
# pdfengines-metadata
|
# pdfengines-metadata
|
||||||
@@ -156,6 +188,7 @@ NO_CONCURRENCY=false
|
|||||||
# stamp
|
# stamp
|
||||||
# pdfengines-rotate
|
# pdfengines-rotate
|
||||||
# rotate
|
# rotate
|
||||||
|
# factur-x
|
||||||
# pdfengines-bookmarks
|
# pdfengines-bookmarks
|
||||||
# bookmarks
|
# bookmarks
|
||||||
# prometheus-metrics
|
# prometheus-metrics
|
||||||
@@ -184,11 +217,19 @@ lint-prettier: ## Lint non-Golang codebase
|
|||||||
|
|
||||||
.PHONY: lint-todo
|
.PHONY: lint-todo
|
||||||
lint-todo: ## Find TODOs in Golang codebase
|
lint-todo: ## Find TODOs in Golang codebase
|
||||||
golangci-lint run --no-config --disable-all --enable godox
|
golangci-lint run --no-config --default=none --enable godox
|
||||||
|
|
||||||
|
# TODO: restore a plain "go fix ./..." once the errorsastype modernizer stops
|
||||||
|
# rewriting this codebase into code that does not compile. Re-check by dropping
|
||||||
|
# the flag and running "make fmt && make lint". Removing the analyzer upstream
|
||||||
|
# makes go fix fail with "flag provided but not defined", so this cannot rot
|
||||||
|
# silently.
|
||||||
|
# errorsastype rewrites errors.As to errors.AsType[T] without checking that T
|
||||||
|
# satisfies error, which breaks on api.HttpError since it does not embed
|
||||||
|
# error. Still broken as of Go 1.27.1.
|
||||||
.PHONY: fmt
|
.PHONY: fmt
|
||||||
fmt: ## Format Golang codebase and "optimize" the dependencies
|
fmt: ## Format Golang codebase and "optimize" the dependencies
|
||||||
go fix ./...
|
go fix -errorsastype=false ./...
|
||||||
golangci-lint fmt
|
golangci-lint fmt
|
||||||
go mod tidy
|
go mod tidy
|
||||||
|
|
||||||
|
|||||||
@@ -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.27.1
|
||||||
|
|
||||||
# ----------------------------------------------
|
# ----------------------------------------------
|
||||||
# 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.15.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).
|
||||||
@@ -59,7 +59,14 @@ RUN go build -o gotenberg -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/
|
|||||||
# ----------------------------------------------
|
# ----------------------------------------------
|
||||||
FROM debian:13-slim AS custom-jre-stage
|
FROM debian:13-slim AS custom-jre-stage
|
||||||
|
|
||||||
RUN apt-get update -qq \
|
# APT_SNAPSHOT busts every layer below it when CI rotates the value, weekly.
|
||||||
|
# Without it a persistent build cache turns the unpinned "apt-get upgrade" into
|
||||||
|
# a cache hit and the published image keeps shipping the package versions that
|
||||||
|
# were current when the cache was first populated.
|
||||||
|
ARG APT_SNAPSHOT=""
|
||||||
|
|
||||||
|
RUN echo "apt snapshot: $APT_SNAPSHOT" \
|
||||||
|
&& apt-get update -qq \
|
||||||
&& apt-get upgrade -yqq \
|
&& apt-get upgrade -yqq \
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends default-jdk-headless binutils
|
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends default-jdk-headless binutils
|
||||||
|
|
||||||
@@ -88,7 +95,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.5.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" \
|
||||||
@@ -114,9 +121,15 @@ FROM base-image-stage AS common-stage
|
|||||||
ARG GOTENBERG_USER_GID=1001
|
ARG GOTENBERG_USER_GID=1001
|
||||||
ARG GOTENBERG_USER_UID=1001
|
ARG GOTENBERG_USER_UID=1001
|
||||||
|
|
||||||
|
# See the note on APT_SNAPSHOT in custom-jre-stage. Declaring it here covers
|
||||||
|
# every "apt-get upgrade" in the gotenberg, gotenberg-chromium and
|
||||||
|
# gotenberg-libreoffice targets too, since all three branch from this stage.
|
||||||
|
ARG APT_SNAPSHOT=""
|
||||||
|
|
||||||
# Create a non-root user.
|
# Create a non-root user.
|
||||||
# All processes in the Docker container will run with this dedicated user.
|
# All processes in the Docker container will run with this dedicated user.
|
||||||
RUN groupadd --gid "$GOTENBERG_USER_GID" gotenberg \
|
RUN echo "apt snapshot: $APT_SNAPSHOT" \
|
||||||
|
&& groupadd --gid "$GOTENBERG_USER_GID" gotenberg \
|
||||||
&& useradd --uid "$GOTENBERG_USER_UID" --gid gotenberg --shell /bin/bash --home /home/gotenberg --no-create-home gotenberg \
|
&& useradd --uid "$GOTENBERG_USER_UID" --gid gotenberg --shell /bin/bash --home /home/gotenberg --no-create-home gotenberg \
|
||||||
&& mkdir /home/gotenberg \
|
&& mkdir /home/gotenberg \
|
||||||
&& chown gotenberg: /home/gotenberg
|
&& chown gotenberg: /home/gotenberg
|
||||||
@@ -125,6 +138,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 +203,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
|
||||||
@@ -245,7 +272,7 @@ RUN echo "deb http://deb.debian.org/debian trixie-backports main" >> /etc/apt/so
|
|||||||
hyphen-no hyphen-or hyphen-pa hyphen-pl hyphen-pt-br hyphen-pt-pt hyphen-ro hyphen-ru hyphen-sk hyphen-sl \
|
hyphen-no hyphen-or hyphen-pa hyphen-pl hyphen-pt-br hyphen-pt-pt hyphen-ro hyphen-ru hyphen-sk hyphen-sl \
|
||||||
hyphen-sr hyphen-sv hyphen-ta hyphen-te hyphen-th hyphen-uk hyphen-zu \
|
hyphen-sr hyphen-sv hyphen-ta hyphen-te hyphen-th hyphen-uk hyphen-zu \
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports \
|
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports \
|
||||||
libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw python3-uno \
|
libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw libreoffice-math python3-uno \
|
||||||
# unoconverter will look for the Python binary, which has to be at version 3.
|
# unoconverter will look for the Python binary, which has to be at version 3.
|
||||||
&& ln -s /usr/bin/python3 /usr/bin/python \
|
&& ln -s /usr/bin/python3 /usr/bin/python \
|
||||||
# Cleanup.
|
# Cleanup.
|
||||||
@@ -268,6 +295,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 +359,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
|
||||||
|
|
||||||
@@ -365,7 +399,7 @@ RUN echo "deb http://deb.debian.org/debian trixie-backports main" >> /etc/apt/so
|
|||||||
hyphen-no hyphen-or hyphen-pa hyphen-pl hyphen-pt-br hyphen-pt-pt hyphen-ro hyphen-ru hyphen-sk hyphen-sl \
|
hyphen-no hyphen-or hyphen-pa hyphen-pl hyphen-pt-br hyphen-pt-pt hyphen-ro hyphen-ru hyphen-sk hyphen-sl \
|
||||||
hyphen-sr hyphen-sv hyphen-ta hyphen-te hyphen-th hyphen-uk hyphen-zu \
|
hyphen-sr hyphen-sv hyphen-ta hyphen-te hyphen-th hyphen-uk hyphen-zu \
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports \
|
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports \
|
||||||
libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw python3-uno \
|
libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw libreoffice-math python3-uno \
|
||||||
# unoconverter will look for the Python binary, which has to be at version 3.
|
# unoconverter will look for the Python binary, which has to be at version 3.
|
||||||
&& ln -s /usr/bin/python3 /usr/bin/python \
|
&& ln -s /usr/bin/python3 /usr/bin/python \
|
||||||
# Cleanup.
|
# Cleanup.
|
||||||
@@ -382,6 +416,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")
|
||||||
@@ -79,7 +80,7 @@ func Run() {
|
|||||||
// Override their values if the corresponding environment variables are
|
// Override their values if the corresponding environment variables are
|
||||||
// set.
|
// set.
|
||||||
fs.VisitAll(func(f *flag.Flag) {
|
fs.VisitAll(func(f *flag.Flag) {
|
||||||
envName := strings.ToUpper(strings.ReplaceAll(f.Name, "-", "_"))
|
envName := gotenberg.EnvVarName(f.Name)
|
||||||
val, ok := os.LookupEnv(envName)
|
val, ok := os.LookupEnv(envName)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -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")
|
||||||
|
|||||||
20
compose.yaml
20
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,7 +31,12 @@ 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-download-from-max-concurrency=${API_DOWNLOAD_FROM_MAX_CONCURRENCY}"
|
||||||
|
- "--api-download-from-max-entries=${API_DOWNLOAD_FROM_MAX_ENTRIES}"
|
||||||
- "--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}"
|
||||||
- "--api-disable-root-route-telemetry=${API_DISABLE_ROOT_ROUTE_TELEMETRY}"
|
- "--api-disable-root-route-telemetry=${API_DISABLE_ROOT_ROUTE_TELEMETRY}"
|
||||||
@@ -48,8 +55,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,15 +71,20 @@ 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}"
|
||||||
- "--pdfengines-convert-engines=${PDFENGINES_CONVERT_ENGINES}"
|
- "--pdfengines-convert-engines=${PDFENGINES_CONVERT_ENGINES}"
|
||||||
|
- "--pdfengines-optimize-images-engines=${PDFENGINES_OPTIMIZE_IMAGES_ENGINES}"
|
||||||
- "--pdfengines-read-metadata-engines=${PDFENGINES_READ_METADATA_ENGINES}"
|
- "--pdfengines-read-metadata-engines=${PDFENGINES_READ_METADATA_ENGINES}"
|
||||||
- "--pdfengines-write-metadata-engines=${PDFENGINES_WRITE_METADATA_ENGINES}"
|
- "--pdfengines-write-metadata-engines=${PDFENGINES_WRITE_METADATA_ENGINES}"
|
||||||
- "--pdfengines-read-bookmarks-engines=${PDFENGINES_READ_BOOKMARKS_ENGINES}"
|
- "--pdfengines-read-bookmarks-engines=${PDFENGINES_READ_BOOKMARKS_ENGINES}"
|
||||||
@@ -80,6 +95,8 @@ 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-max-concurrency=${PDFENGINES_MAX_CONCURRENCY}"
|
||||||
- "--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 +106,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}"
|
||||||
|
|||||||
155
go.mod
155
go.mod
@@ -1,40 +1,42 @@
|
|||||||
module github.com/gotenberg/gotenberg/v8
|
module github.com/gotenberg/gotenberg/v8
|
||||||
|
|
||||||
go 1.26.2
|
go 1.27.1
|
||||||
|
|
||||||
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/coreos/go-oidc/v3 v3.21.0
|
||||||
|
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-20260417124207-7d523f7318df
|
github.com/gomarkdown/markdown v0.0.0-20260824154242-13c5cf49db8d
|
||||||
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.2
|
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.2
|
github.com/moby/moby/api v1.56.0
|
||||||
github.com/moby/moby/client v0.4.1
|
github.com/moby/moby/client v0.6.0
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.24.1
|
||||||
github.com/shirou/gopsutil/v4 v4.26.4
|
github.com/shirou/gopsutil/v4 v4.26.8
|
||||||
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.12.1
|
||||||
github.com/testcontainers/testcontainers-go v0.42.0
|
github.com/testcontainers/testcontainers-go v0.44.0
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0
|
go.opentelemetry.io/contrib/bridges/otelslog v0.20.1
|
||||||
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0
|
go.opentelemetry.io/contrib/exporters/autoexport v0.71.0
|
||||||
go.opentelemetry.io/otel v1.44.0
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0
|
||||||
go.opentelemetry.io/otel/log v0.20.0
|
go.opentelemetry.io/otel v1.46.0
|
||||||
go.opentelemetry.io/otel/metric v1.44.0
|
go.opentelemetry.io/otel/log v0.22.0
|
||||||
go.opentelemetry.io/otel/sdk v1.44.0
|
go.opentelemetry.io/otel/metric v1.46.0
|
||||||
go.opentelemetry.io/otel/sdk/log v0.20.0
|
go.opentelemetry.io/otel/sdk v1.46.0
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.44.0
|
go.opentelemetry.io/otel/sdk/log v0.22.0
|
||||||
go.opentelemetry.io/otel/trace v1.44.0
|
go.opentelemetry.io/otel/sdk/metric v1.46.0
|
||||||
golang.org/x/net v0.55.0
|
go.opentelemetry.io/otel/trace v1.46.0
|
||||||
golang.org/x/sync v0.20.0
|
golang.org/x/net v0.58.0
|
||||||
golang.org/x/term v0.43.0
|
golang.org/x/sync v0.23.0
|
||||||
golang.org/x/text v0.37.0
|
golang.org/x/term v0.45.0
|
||||||
|
golang.org/x/text v0.41.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -42,11 +44,11 @@ require (
|
|||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/STARRY-S/zip v0.2.3 // indirect
|
github.com/STARRY-S/zip v0.2.3 // indirect
|
||||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
github.com/andybalholm/brotli v1.2.3 // indirect
|
||||||
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.4 // indirect
|
github.com/bodgit/sevenzip v1.6.5 // 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
|
||||||
@@ -57,89 +59,86 @@ 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.1 // indirect
|
||||||
github.com/cucumber/messages/go/v21 v21.0.1 // indirect
|
github.com/cucumber/messages/go/v34 v34.2.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.7.0 // indirect
|
github.com/docker/go-connections v0.8.1 // 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.1 // indirect
|
github.com/ebitengine/purego v0.11.0 // 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-20260520185125-572e7c383686 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.5 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // 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.29.0 // indirect
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.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.6 // indirect
|
github.com/klauspost/compress v1.20.0 // 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-20260802145828-341c2f0c90b5 // indirect
|
||||||
github.com/magiconair/properties v1.8.10 // indirect
|
github.com/magiconair/properties v1.18.11 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
||||||
github.com/minio/minlz v1.1.1 // indirect
|
github.com/minio/minlz v1.2.0 // 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.3.3 // 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.1 // indirect
|
||||||
github.com/moby/sys/userns v0.1.0 // indirect
|
github.com/moby/sys/userns v0.2.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.3 // indirect
|
github.com/nwaples/rardecode/v2 v2.4.1 // 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.29 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/prometheus/client_model v0.6.3 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/common v0.71.0 // indirect
|
||||||
github.com/prometheus/common v0.67.5 // 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.22.0 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
github.com/sirupsen/logrus v1.10.2 // 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/stangelandcl/ppmd v0.1.1 // indirect
|
github.com/stangelandcl/ppmd v0.1.1 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
github.com/ulikunitz/xz v0.5.16 // 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.69.0 // indirect
|
go.opentelemetry.io/contrib/bridges/prometheus v0.71.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.22.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.22.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect
|
go.opentelemetry.io/otel/exporters/prometheus v0.68.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0 // indirect
|
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.22.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 // indirect
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.46.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 // indirect
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 // indirect
|
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
go.yaml.in/yaml/v3 v3.0.5 // 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.52.0 // indirect
|
golang.org/x/crypto v0.56.0 // indirect
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
golang.org/x/oauth2 v0.36.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-20260526163538-3dc84a4a5aaa // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a // indirect
|
||||||
google.golang.org/grpc v1.81.1 // indirect
|
google.golang.org/grpc v1.83.2 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.12 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
328
go.sum
328
go.sum
@@ -10,16 +10,16 @@ github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
|
|||||||
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
|
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
|
||||||
github.com/alexliesenfeld/health v0.8.1 h1:wdE3vt+cbJotiR8DGDBZPKHDFoJbAoWEfQTcqrmedUg=
|
github.com/alexliesenfeld/health v0.8.1 h1:wdE3vt+cbJotiR8DGDBZPKHDFoJbAoWEfQTcqrmedUg=
|
||||||
github.com/alexliesenfeld/health v0.8.1/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc=
|
github.com/alexliesenfeld/health v0.8.1/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc=
|
||||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
github.com/andybalholm/brotli v1.2.3 h1:8H1qwOkl2LPfjf3YezB90JnCliZb6SInJ/OJkEbA5NQ=
|
||||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.3/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
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.4 h1:iHiVJfxbrB6RF4X+snI2MpVgNBKmVfGaTqZGNlMQIU0=
|
github.com/bodgit/sevenzip v1.6.5 h1:7H7BxgmeX0j6UX42lH+KXQ92WgMQJ49DoocFdfHbCng=
|
||||||
github.com/bodgit/sevenzip v1.6.4/go.mod h1:ZtNi5KNgHXeXg1G7WiF0IWSuFE2eG6lt/cTGlvuirO0=
|
github.com/bodgit/sevenzip v1.6.5/go.mod h1:GhuB6Lq1xCpP1sps+horjZ8lgiKPJcy2zUX3prla9wc=
|
||||||
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=
|
||||||
@@ -42,43 +42,44 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
|||||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||||
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/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||||
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.1 h1:ao9TVJmBb8uNLEcjMDFbhsoL3yC7gMpRLHgP1ZGfFWA=
|
||||||
github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0=
|
github.com/cucumber/gherkin/go/v42 v42.0.1/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.1 h1:qBPEl+HhNJuRX8Kjaw1Pm60KOODZf2/4WQAi0SR/neE=
|
||||||
github.com/cucumber/messages/go/v21 v21.0.1/go.mod h1:zheH/2HS9JLVFukdrsPWoPdmUtmYQAQPLk7w5vWsk5s=
|
github.com/cucumber/messages/go/v34 v34.2.1/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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
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.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M=
|
||||||
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
github.com/docker/go-connections v0.8.1/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.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI=
|
||||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
|
||||||
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-20260520185125-572e7c383686 h1:NZBJxCpbHS1gzS6xAmyxbJznosZIIPk9IB42v62UvKA=
|
github.com/go-jose/go-jose/v4 v4.1.5 h1:RjgjO2LOtWOJKUC5wpwY9LR3B3vwVAz6JS2YHfYU6eA=
|
||||||
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
github.com/go-jose/go-jose/v4 v4.1.5/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE=
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/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 +91,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-20260417124207-7d523f7318df h1:Mwihr/o+v4L5h56rwHLOE20+hh7Okhwno5BHz3zDuao=
|
github.com/gomarkdown/markdown v0.0.0-20260824154242-13c5cf49db8d h1:8VtgBGEPLZ2Yn0Fuh6Pwmy3qF6indeaqy8mrBMbUKRQ=
|
||||||
github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
github.com/gomarkdown/markdown v0.0.0-20260824154242-13c5cf49db8d/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 +102,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.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
|
||||||
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,102 +123,91 @@ 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.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
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/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/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.2 h1:nnh2sCzGCVYnU+wCisMPiYapEg/QVo/gcI9ePKg5/T4=
|
github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs=
|
||||||
github.com/labstack/echo/v4 v4.15.2/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI=
|
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=
|
||||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
|
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
|
||||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
github.com/magiconair/properties v1.18.11 h1:j5ozYZl0zCjG7ahMDH0GWIobOvvUzT0BdAguG0ViKy0=
|
||||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
github.com/magiconair/properties v1.18.11/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.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
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.1 h1:OGmft1V6AnI/Wme332U6bhG54nxEan+VFgkD7lat4KM=
|
github.com/minio/minlz v1.2.0 h1:6IOBuiHg04QxvbFfgFLT/9sMaO/UhL7S+ApW1mK8q5A=
|
||||||
github.com/minio/minlz v1.1.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
|
github.com/minio/minlz v1.2.0/go.mod h1:Ls9H7nlkASeCcdl5thjVD5Eraj6z+zGa7xtq57jIKD4=
|
||||||
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.3.3 h1:OxxR9paxsluYi+zDUEXTTaIxtkK3viymW+Ka7vRhhME=
|
||||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
github.com/moby/go-archive v0.3.3/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE=
|
||||||
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
|
github.com/moby/moby/api v1.56.0 h1:GQzua3NA599ASSIICx0iFgiJeO9YkdDARvQsm23ZZuQ=
|
||||||
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
github.com/moby/moby/api v1.56.0/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk=
|
||||||
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
|
github.com/moby/moby/client v0.6.0 h1:AJjEB21QPbXSXjDsZorFBoDZPhMrfbpaPLgSMAW9Bgs=
|
||||||
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
|
github.com/moby/moby/client v0.6.0/go.mod h1:OCo00wNRyA3m4lmJ228W3JbyCN4ZNNYjpOXiJydBdcQ=
|
||||||
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/mount v0.3.5 h1:eS3fsZTjHaBihwjp4/+5Z3jxqLXYsbwxqpVSfFv3M00=
|
||||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
github.com/moby/sys/mount v0.3.5/go.mod h1:WUQDO+/uCiCIkIztx8SrwIDVn2dtMFRBebRhpDFT71M=
|
||||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
|
||||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
|
||||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
|
||||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
|
||||||
|
github.com/moby/sys/user v0.4.1 h1:RgjRlaDKi/Xmyrz4t8lyzXT6v2ooFeO/7xtchmhVWE0=
|
||||||
|
github.com/moby/sys/user v0.4.1/go.mod h1:E9QsW5WRe1kUAf7kW8hXKwu1uhsZEAdPLYHYSDudF4Y=
|
||||||
|
github.com/moby/sys/userns v0.2.0 h1:nEtDtp7NCV/6dutSklNe8FrENPwFdc4mXnZqC/JWgXM=
|
||||||
|
github.com/moby/sys/userns v0.2.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
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.3 h1:qaVuy3ChZDbAQZshPLjHeNJKF3Cru8uo9jmgveKIy2A=
|
github.com/nwaples/rardecode/v2 v2.4.1 h1:F7zNW2LdAuuBThHWXQaiFUGVD/sef299NfWSB1nHAl4=
|
||||||
github.com/nwaples/rardecode/v2 v2.2.3/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
|
github.com/nwaples/rardecode/v2 v2.4.1/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.29 h1:CDQY6qZOLI4DW0Nx6R1vRrifrCeQHnNXkMb0hZWXFjg=
|
||||||
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
github.com/pierrec/lz4/v4 v4.1.29/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/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-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/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.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
|
||||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8=
|
||||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk=
|
||||||
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.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
|
||||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/shirou/gopsutil/v4 v4.26.8 h1:YQMTF/1J50B5+Y0vlo1eDRf5DoR7Gk69hY+8wjYkQeo=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/shirou/gopsutil/v4 v4.26.8/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY=
|
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.4/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
|
||||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
|
||||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
|
||||||
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
|
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 h1:c25QazhlWUn5nmR1QOzafKhQxBicAr7GGCKER2aJ8H8=
|
||||||
@@ -236,18 +220,17 @@ 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.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI=
|
||||||
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
|
github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
|
||||||
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
|
|
||||||
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||||
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
||||||
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||||
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
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.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0=
|
||||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
github.com/ulikunitz/xz v0.5.16/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
@@ -258,97 +241,98 @@ 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.19.0 h1:5RgvxieNq9tS3ewrV1vnODvbHPfKUIJcYtF9Cvz+6aQ=
|
go.opentelemetry.io/contrib/bridges/otelslog v0.20.1 h1:5sHc4ToTFjfSZCtGAAM6jPunICAmJX73htv372T4ipc=
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0/go.mod h1:iTBIdNwx/xmUhfgJs6+84S4dIK059811cO1eUBjKcHY=
|
go.opentelemetry.io/contrib/bridges/otelslog v0.20.1/go.mod h1:oa6kgvyz/3GYW04dohd0++xJIH4xdQY8PAbpeCMaM8M=
|
||||||
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0 h1:saQoWg5845Q8TojpqeVStS7zGwVZ6bc5W2PJavTPiBM=
|
go.opentelemetry.io/contrib/bridges/prometheus v0.71.0 h1:9qgxsFLskbDMXl8WMqThoF6w8yGJgCumn9qRc67OmnI=
|
||||||
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0/go.mod h1:AAaS6xs5AyqMdR3Ir0nSWK+QudL2XM8Vbw5INzUxNc8=
|
go.opentelemetry.io/contrib/bridges/prometheus v0.71.0/go.mod h1:2rCjF4F2siiTeLCzJsaGZ3CK0XIoimCSKXEBPdv+Je0=
|
||||||
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0 h1:R3jsCoTIzv0BiYNhW0axyswn/6SMJ8xL1OuGxvni1Kw=
|
go.opentelemetry.io/contrib/exporters/autoexport v0.71.0 h1:VCsJbp0YLyPtx2tu5Vgv2a2/qLoaMCj8hT2uZ34+Mx0=
|
||||||
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0/go.mod h1:m07gqyr2QhQxKOKb5vqKCCBtLH3uqlNYR7PU/FISXVU=
|
go.opentelemetry.io/contrib/exporters/autoexport v0.71.0/go.mod h1:qxZqn7e10f6ajmMCkg/47rMS7qQYfaOl2nj/4aytHUQ=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU=
|
||||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
|
||||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE=
|
||||||
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.22.0 h1:Bu39F5tzJct+f2IZbB8989fwyTps3c8e7EsUQsz+vs8=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.22.0/go.mod h1:dJUwod88EsFgYCqrDHaSPzhiY9pBUpt0d85/qSfua7k=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.22.0 h1:lYk7RmxdLK865qLwibroNGldHa1U7SWKYYvNjlK7PIo=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0/go.mod h1:MP4eemTiI9zC8fgg+DYynhYDYf3ba72S376TvP+Ye0Q=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.22.0/go.mod h1:6GvlND0H0xdUJanOtIAn0xfwLkauh1tmsYEEVSMDdqY=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0 h1:qkDYCAFiZXLcs1L4aY+tP2wguQ4kURANqHOQMA2et2s=
|
||||||
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/otlpmetricgrpc v1.46.0/go.mod h1:tkipS4DRzmpAmvg+Gw4++O1IdDq6TVDnvnYU6cmbQVs=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA=
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.0 h1:AP23h/mFgb/lc7tdck1Kfn9qxsM8TAeNPCU5C3pzaps=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo=
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.0/go.mod h1:K4EqCe1b4kGk5WR690ntg9LaBfsPoV32FwthbyoptuA=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 h1:w53CDeOA/Kurp7yRsegSr6pbbr759dOvJ+yNmWM6Hxs=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0/go.mod h1:BOmGMCbAtvcJiSJ+hLuhgPLdDbimnraSl8irz3iY8sY=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 h1:KrC1YrQeSt46ITMWAbgQx1M1eV1/1TKzttrBzymPmss=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0/go.mod h1:zDSEzoEqsOrgBeGvH66KRgxh90VonFyJqBHA0Pk3+rM=
|
||||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0 h1:vkrK8PAznv2NKt2r+kdu252ccGzkEqLc2aSXbQIALYQ=
|
go.opentelemetry.io/otel/exporters/prometheus v0.68.0 h1:QOf2IftqQwITVRJpnn0M7M9ZCbgWfxz4P7i9C9yc2N4=
|
||||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0/go.mod h1:V/UB6D3vMF/UBOL5igAsAYnk1nG/bzYYTzvsB16cy7o=
|
go.opentelemetry.io/otel/exporters/prometheus v0.68.0/go.mod h1:bgSvqu2TWGXiz7yr5UTMfObH8oqxJWHTnubQ3ef9BO4=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 h1:aZfdmtI6QU/DAPD4b7YZ5zuJgewxO1EW9miOZklqleU=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.22.0 h1:kvMAiLEudKmk+CSG+iYbU8vTUGNNDaf/V09OO5lrTwI=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0/go.mod h1:isNl10/Om5CBWu9jj8WOb2+tJLbCVXDgqwzCaJMnJ6w=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.22.0/go.mod h1:L9Dlksri+MdT1cb2gIiA1cJJYW3Y92ipvDjNxYEyaDI=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.46.0 h1:PR9eAf7o0dQs3hshZNZpE9aW2dXWX/KdDf6pJilVD3U=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.46.0/go.mod h1:2Z4KyNdH1uuzivdinyfGsxzNNT/Rl45pwtVwfYVI0xk=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs=
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 h1:KdRxPiAoMptR3vfWzvjjvutTsSiwbC2uG0496rzZNfo=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0=
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0/go.mod h1:K/qSA+3G7Eovxi4K09wzrAgkWRnosS0DAOZeEpve7sM=
|
||||||
go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs=
|
go.opentelemetry.io/otel/log v0.22.0 h1:5DBNnfvaJ6CVdkJ+Jle8Tzs50aSSv49TXGj9XRsEYw0=
|
||||||
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E=
|
go.opentelemetry.io/otel/log v0.22.0/go.mod h1:gzOt/R67vF2GniAqWu8Qv0SXy89f71muHcrkz76PCdc=
|
||||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
|
||||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
|
||||||
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
|
go.opentelemetry.io/otel/metric/x v0.68.0 h1:TA/cBT23D3MnxYPwHL7YFOdYGdx0A0v+s7Mzotpd1dU=
|
||||||
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
|
go.opentelemetry.io/otel/metric/x v0.68.0/go.mod h1:agudOmvWhwUTjgibWDzxD2PoWYnpw5Ht5jISYOD2Hd4=
|
||||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI=
|
||||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM=
|
||||||
go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY=
|
go.opentelemetry.io/otel/sdk/log v0.22.0 h1:PRL+s6P63XT4E/bheEflopPUpVxuvANqZwtt89yhoGk=
|
||||||
go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU=
|
go.opentelemetry.io/otel/sdk/log v0.22.0/go.mod h1:JNp0sBELrjCTcu5W3GzABVypeU6vDJjBS+X0JISuz+g=
|
||||||
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg=
|
go.opentelemetry.io/otel/sdk/log/logtest v0.22.0 h1:infPnfNrhCNgOUZRs3gWUg8vhoBUHihq02gwK05gzlg=
|
||||||
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M=
|
go.opentelemetry.io/otel/sdk/log/logtest v0.22.0/go.mod h1:gkQZA3z15Bv3KU9vigBTi8dFechSozRP7v94X4VZv+s=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4=
|
||||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
|
||||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
|
||||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
|
||||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
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=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
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.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
||||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
||||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
|
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||||
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||||
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-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a h1:i3TAXhpKc7TUP1VAPiBBrv45kamjoizCC3rOC0cAbOs=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:CvYJHpbzPlT0fb/PsgtAamdwru/GVxUsomFdXTpOTI8=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a h1:3Dnd1cDaZlB68lziofO+bJXpjOy8UfRv8Unt+yH8tQ4=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
|
||||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
|
||||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.12/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=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||||
|
|||||||
154
package-lock.json
generated
154
package-lock.json
generated
@@ -5,50 +5,106 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.9.6",
|
||||||
"prettier-plugin-gherkin": "^3.1.3",
|
"prettier-plugin-gherkin": "^4.0.0",
|
||||||
"prettier-plugin-sh": "^0.18.0"
|
"prettier-plugin-sh": "^0.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cucumber/gherkin": {
|
"node_modules/@cucumber/gherkin": {
|
||||||
"version": "32.2.0",
|
"version": "39.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-32.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-39.1.0.tgz",
|
||||||
"integrity": "sha512-X8xuVhSIqlUjxSRifRJ7t0TycVWyX58fygJH3wDNmHINLg9sYEkvQT0SO2G5YlRZnYc11TIFr4YPenscvdlBIw==",
|
"integrity": "sha512-pqmSO2bUWxJm3TbNrKXlDaHjL6c77+ez9kWmfCd9oRPeTRPEVH3spZvpAqdXYWOZYSNYwWFCAAeZ4RGpkauNoQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cucumber/messages": ">=19.1.4 <28"
|
"@cucumber/messages": ">=31.0.0 <33"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cucumber/messages": {
|
"node_modules/@cucumber/messages": {
|
||||||
"version": "27.2.0",
|
"version": "32.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-27.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-32.3.1.tgz",
|
||||||
"integrity": "sha512-f2o/HqKHgsqzFLdq6fAhfG1FNOQPdBdyMGpKwhb7hZqg0yZtx9BVqkTyuoNk83Fcvk3wjMVfouFXXHNEk4nddA==",
|
"integrity": "sha512-yNQq1KoXRYaEKrWMFmpUQX7TdeQuU9jeGgJAZ3dArTsC/T4NpJ6DnqaJIIgwPnz/wtQIQTNX7/h0rOuF5xY4qQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/uuid": "10.0.0",
|
|
||||||
"class-transformer": "0.5.1",
|
"class-transformer": "0.5.1",
|
||||||
"reflect-metadata": "0.2.2",
|
"reflect-metadata": "0.2.2"
|
||||||
"uuid": "11.0.5"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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/@types/uuid": {
|
"node_modules/@reteps/dockerfmt-darwin-arm64": {
|
||||||
"version": "10.0.0",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt-darwin-arm64/-/dockerfmt-darwin-arm64-0.5.4.tgz",
|
||||||
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
"integrity": "sha512-urMqV+dQyvVI8/WrXwClX9e1PEyS35wFdwJjpZYmL09AkV4Io5U1oam8UBKK7jZk0+YsdF88ay6e86Kn6DIyQg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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/class-transformer": {
|
"node_modules/class-transformer": {
|
||||||
"version": "0.5.1",
|
"version": "0.5.1",
|
||||||
@@ -58,9 +114,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": {
|
||||||
@@ -74,26 +130,26 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prettier-plugin-gherkin": {
|
"node_modules/prettier-plugin-gherkin": {
|
||||||
"version": "3.1.3",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/prettier-plugin-gherkin/-/prettier-plugin-gherkin-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/prettier-plugin-gherkin/-/prettier-plugin-gherkin-4.0.0.tgz",
|
||||||
"integrity": "sha512-w9uB413NlSi8ZQwpexyu+ttriJJ88eZLV0x88ZTkzkLZyHYEX5wrNtaCx/yFYviIu/tuwsBqDPM47VODnIV/hw==",
|
"integrity": "sha512-EBDwV1Ou9rG+seoh7jcwZ6sXXyHwsbUks5TeSyrlrLaTdz+/qYvnHuwY8rk7mb6/+M1ZGuY4/SiVc68ybwvJOA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cucumber/gherkin": "^32.0.0",
|
"@cucumber/gherkin": "^39.1.0",
|
||||||
"@cucumber/messages": "^27.2.0",
|
"@cucumber/messages": "^32.3.1",
|
||||||
"prettier": "^3.5.3"
|
"prettier": "^3.5.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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,41 +169,17 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"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": {
|
|
||||||
"version": "11.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz",
|
|
||||||
"integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==",
|
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
|
||||||
"https://github.com/sponsors/broofa",
|
|
||||||
"https://github.com/sponsors/ctavan"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"uuid": "dist/esm/bin/uuid"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.9.6",
|
||||||
"prettier-plugin-gherkin": "^3.1.3",
|
"prettier-plugin-gherkin": "^4.0.0",
|
||||||
"prettier-plugin-sh": "^0.18.0"
|
"prettier-plugin-sh": "^0.19.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
635
pkg/gotenberg/allowlist.go
Normal file
635
pkg/gotenberg/allowlist.go
Normal file
@@ -0,0 +1,635 @@
|
|||||||
|
package gotenberg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AllowListRisk classifies why an allow-list pattern is dangerous. A URL that
|
||||||
|
// matches an allow-list skips the private and public IP checks, so a pattern
|
||||||
|
// that matches more than its author intended silently widens outbound access.
|
||||||
|
// See [AuditAllowList].
|
||||||
|
type AllowListRisk string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AllowListRiskUnanchored marks a pattern with no leading "^". regexp2
|
||||||
|
// searches rather than matches, so the pattern hits anywhere in the URL,
|
||||||
|
// including the query string.
|
||||||
|
AllowListRiskUnanchored AllowListRisk = "unanchored"
|
||||||
|
|
||||||
|
// AllowListRiskUnanchoredBranch marks an alternation whose later branches
|
||||||
|
// have no leading "^". Anchoring only the first branch is a common slip.
|
||||||
|
AllowListRiskUnanchoredBranch AllowListRisk = "unanchored-branch"
|
||||||
|
|
||||||
|
// AllowListRiskCatchAll marks a pattern with no literal prefix, such as
|
||||||
|
// ".+", which matches every URL and disables filtering entirely.
|
||||||
|
AllowListRiskCatchAll AllowListRisk = "catch-all"
|
||||||
|
|
||||||
|
// AllowListRiskOpenHost marks a pattern whose host is not terminated, so
|
||||||
|
// it also matches attacker-chosen suffix hosts. For example
|
||||||
|
// "^https://trusted\.example\.com" matches
|
||||||
|
// "https://trusted.example.com.attacker.example/".
|
||||||
|
AllowListRiskOpenHost AllowListRisk = "open-host"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AllowListFinding reports one risky entry of an allow-list.
|
||||||
|
type AllowListFinding struct {
|
||||||
|
// Index is the zero-based position of the pattern within the flag value.
|
||||||
|
Index int
|
||||||
|
|
||||||
|
// Pattern is the operator's pattern, verbatim.
|
||||||
|
Pattern string
|
||||||
|
|
||||||
|
// Risk is why the pattern is dangerous.
|
||||||
|
Risk AllowListRisk
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxAuditedPatternLength bounds the patterns [AuditAllowList] inspects. A
|
||||||
|
// pathological pattern is not worth walking, and reporting nothing is better
|
||||||
|
// than reporting a partial verdict.
|
||||||
|
const maxAuditedPatternLength = 4096
|
||||||
|
|
||||||
|
// AuditAllowList reports the entries of an allow-list that match more URLs
|
||||||
|
// than their author is likely to intend. It is a lint over the pattern source,
|
||||||
|
// not a parser: it recognizes the shapes that are dangerous in practice and
|
||||||
|
// stays silent when it cannot be sure.
|
||||||
|
//
|
||||||
|
// Callers use the findings to warn operators. Never use them to reject a
|
||||||
|
// configuration: existing deployments rely on loose patterns, and a pattern
|
||||||
|
// this function does not flag is not thereby safe.
|
||||||
|
func AuditAllowList(patterns []string) []AllowListFinding {
|
||||||
|
var findings []AllowListFinding
|
||||||
|
|
||||||
|
for i, pattern := range patterns {
|
||||||
|
if pattern == "" || len(pattern) > maxAuditedPatternLength {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
risk, ok := auditPattern(pattern)
|
||||||
|
if ok {
|
||||||
|
findings = append(findings, AllowListFinding{Index: i, Pattern: pattern, Risk: risk})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return findings
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditPattern classifies a single pattern, reporting the first risk found.
|
||||||
|
func auditPattern(pattern string) (AllowListRisk, bool) {
|
||||||
|
body := trimInlineFlags(pattern)
|
||||||
|
|
||||||
|
branches := splitTopLevelAlternation(body)
|
||||||
|
for i, branch := range branches {
|
||||||
|
branch = strings.TrimSpace(branch)
|
||||||
|
|
||||||
|
anchored := hasStartAnchor(branch)
|
||||||
|
rest := strings.TrimPrefix(strings.TrimPrefix(branch, `\A`), "^")
|
||||||
|
|
||||||
|
// Catch-all first: a pattern that constrains nothing matches every URL
|
||||||
|
// whether or not it is anchored, and saying so is more useful than
|
||||||
|
// telling the operator to anchor it.
|
||||||
|
if literalPrefix(rest) == "" {
|
||||||
|
return AllowListRiskCatchAll, true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !anchored {
|
||||||
|
if i == 0 {
|
||||||
|
return AllowListRiskUnanchored, true
|
||||||
|
}
|
||||||
|
return AllowListRiskUnanchoredBranch, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// A lookaround invalidates the token walk, so skip the host check for
|
||||||
|
// this branch rather than guess. The anchor and catch-all checks above
|
||||||
|
// still applied.
|
||||||
|
if containsLookaround(rest) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if hostIsOpen(rest) {
|
||||||
|
return AllowListRiskOpenHost, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// trimInlineFlags removes a leading inline flag group such as "(?i)" so that
|
||||||
|
// the anchor check sees the pattern proper.
|
||||||
|
func trimInlineFlags(pattern string) string {
|
||||||
|
if !strings.HasPrefix(pattern, "(?") {
|
||||||
|
return pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
end := strings.Index(pattern, ")")
|
||||||
|
if end == -1 {
|
||||||
|
return pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a flag group qualifies. "(?:", "(?=", "(?!" and "(?<" open a real
|
||||||
|
// group and must stay.
|
||||||
|
flags := pattern[2:end]
|
||||||
|
if flags == "" || strings.ContainsAny(flags, ":=!<") {
|
||||||
|
return pattern
|
||||||
|
}
|
||||||
|
for _, r := range flags {
|
||||||
|
if !strings.ContainsRune("imsUx-", r) {
|
||||||
|
return pattern
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pattern[end+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasStartAnchor reports whether branch begins with a start-of-input anchor.
|
||||||
|
func hasStartAnchor(branch string) bool {
|
||||||
|
return strings.HasPrefix(branch, "^") || strings.HasPrefix(branch, `\A`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsLookaround reports whether the pattern uses a lookaround, which the
|
||||||
|
// token walk in [hostIsOpen] cannot reason about.
|
||||||
|
func containsLookaround(s string) bool {
|
||||||
|
return strings.Contains(s, "(?=") || strings.Contains(s, "(?!") || strings.Contains(s, "(?<")
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitTopLevelAlternation splits on "|" at paren depth zero, honoring escapes
|
||||||
|
// and character classes.
|
||||||
|
func splitTopLevelAlternation(s string) []string {
|
||||||
|
var (
|
||||||
|
parts []string
|
||||||
|
current strings.Builder
|
||||||
|
depth int
|
||||||
|
inClass bool
|
||||||
|
)
|
||||||
|
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case c == '\\' && i+1 < len(s):
|
||||||
|
current.WriteByte(c)
|
||||||
|
current.WriteByte(s[i+1])
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
case c == '[' && !inClass:
|
||||||
|
inClass = true
|
||||||
|
case c == ']' && inClass:
|
||||||
|
inClass = false
|
||||||
|
case c == '(' && !inClass:
|
||||||
|
depth++
|
||||||
|
case c == ')' && !inClass:
|
||||||
|
depth--
|
||||||
|
case c == '|' && !inClass && depth == 0:
|
||||||
|
parts = append(parts, current.String())
|
||||||
|
current.Reset()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
current.WriteByte(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts = append(parts, current.String())
|
||||||
|
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
// literalPrefix returns the characters a matching URL must start with. It
|
||||||
|
// stops at the first optional or non-literal token, and descends one level
|
||||||
|
// into a leading mandatory group so that "^(https|http)://" is not mistaken
|
||||||
|
// for a catch-all. An empty result means the pattern constrains nothing.
|
||||||
|
func literalPrefix(s string) string {
|
||||||
|
var prefix strings.Builder
|
||||||
|
|
||||||
|
for i := 0; i < len(s); {
|
||||||
|
// A group: descend once when it is mandatory, otherwise stop.
|
||||||
|
if s[i] == '(' {
|
||||||
|
end := matchingParen(s, i)
|
||||||
|
if end == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if isQuantified(s, end+1) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
inner := trimInlineFlags(s[i+1 : end])
|
||||||
|
branches := splitTopLevelAlternation(inner)
|
||||||
|
|
||||||
|
common := literalPrefix(branches[0])
|
||||||
|
for _, b := range branches[1:] {
|
||||||
|
common = commonPrefix(common, literalPrefix(b))
|
||||||
|
}
|
||||||
|
prefix.WriteString(common)
|
||||||
|
|
||||||
|
// Only the leading group is worth descending into.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
var token string
|
||||||
|
switch {
|
||||||
|
case s[i] == '\\' && i+1 < len(s):
|
||||||
|
token = s[i : i+2]
|
||||||
|
case s[i] == '[':
|
||||||
|
end := matchingBracket(s, i)
|
||||||
|
if end == -1 {
|
||||||
|
return prefix.String()
|
||||||
|
}
|
||||||
|
token = s[i : end+1]
|
||||||
|
default:
|
||||||
|
token = s[i : i+1]
|
||||||
|
}
|
||||||
|
|
||||||
|
next := i + len(token)
|
||||||
|
if isQuantified(s, next) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a plain literal or an escaped literal contributes.
|
||||||
|
switch {
|
||||||
|
case len(token) == 2 && token[0] == '\\' && !isEscapeClass(token[1]):
|
||||||
|
prefix.WriteByte(token[1])
|
||||||
|
case len(token) == 1 && !strings.ContainsAny(token, `.[]()^$*+?{}|`):
|
||||||
|
prefix.WriteByte(token[0])
|
||||||
|
default:
|
||||||
|
return prefix.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
i = next
|
||||||
|
}
|
||||||
|
|
||||||
|
return prefix.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostIsOpen reports whether the authority part of the pattern can be left
|
||||||
|
// without crossing a terminator, which means the pattern also matches
|
||||||
|
// attacker-chosen suffix hosts or userinfo.
|
||||||
|
//
|
||||||
|
// It walks the tokens after "://" and classifies each one. A terminator ends
|
||||||
|
// the authority, so the pattern is safe. A crosser can match "@", "?" or "#"
|
||||||
|
// and therefore lets a matching URL escape the authority, so the pattern is
|
||||||
|
// open. Reaching the end without a terminator is open too, which is the
|
||||||
|
// classic "^https://trusted\.example\.com" case.
|
||||||
|
func hostIsOpen(s string) bool {
|
||||||
|
_, after, ok := strings.Cut(s, "://")
|
||||||
|
if !ok {
|
||||||
|
// No authority to reason about, for example "^file:///tmp/".
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
rest := after
|
||||||
|
|
||||||
|
for i := 0; i < len(rest); {
|
||||||
|
var token string
|
||||||
|
switch {
|
||||||
|
case rest[i] == '\\' && i+1 < len(rest):
|
||||||
|
token = rest[i : i+2]
|
||||||
|
case rest[i] == '[':
|
||||||
|
end := matchingBracket(rest, i)
|
||||||
|
if end == -1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
token = rest[i : end+1]
|
||||||
|
case rest[i] == '(':
|
||||||
|
end := matchingParen(rest, i)
|
||||||
|
if end == -1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
token = rest[i : end+1]
|
||||||
|
default:
|
||||||
|
token = rest[i : i+1]
|
||||||
|
}
|
||||||
|
|
||||||
|
next := i + len(token)
|
||||||
|
optional := isOptionalQuantifier(rest, next)
|
||||||
|
|
||||||
|
switch classifyHostToken(token) {
|
||||||
|
case hostTokenTerminator:
|
||||||
|
// An optional terminator does not end anything, since the URL may
|
||||||
|
// match without it.
|
||||||
|
if !optional {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case hostTokenCrosser:
|
||||||
|
return true
|
||||||
|
case hostTokenNeutral:
|
||||||
|
// Part of the host itself, so keep walking.
|
||||||
|
}
|
||||||
|
|
||||||
|
i = next
|
||||||
|
for i < len(rest) && isQuantifierByte(rest[i]) {
|
||||||
|
if rest[i] == '{' {
|
||||||
|
end := strings.IndexByte(rest[i:], '}')
|
||||||
|
if end == -1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
i += end + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostTokenKind is how a token affects the walk in [hostIsOpen].
|
||||||
|
type hostTokenKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
hostTokenNeutral hostTokenKind = iota
|
||||||
|
hostTokenTerminator
|
||||||
|
hostTokenCrosser
|
||||||
|
)
|
||||||
|
|
||||||
|
// hostTerminators are the characters that end the authority of a URL.
|
||||||
|
const hostTerminators = "/:#?"
|
||||||
|
|
||||||
|
// crosserClassChars are the characters that, if a class can match them, let a
|
||||||
|
// match escape the authority. "/" is deliberately absent: it ends the
|
||||||
|
// authority rather than escaping it, so a class such as "[:/]" is safe.
|
||||||
|
const crosserClassChars = "@?#"
|
||||||
|
|
||||||
|
// classifyHostToken classifies one token of the authority walk.
|
||||||
|
func classifyHostToken(token string) hostTokenKind {
|
||||||
|
switch {
|
||||||
|
case token == ".":
|
||||||
|
// The wildcard matches "@", "#" and "?", so a host built on it can be
|
||||||
|
// left without ever reaching a terminator.
|
||||||
|
return hostTokenCrosser
|
||||||
|
|
||||||
|
case token == "$":
|
||||||
|
return hostTokenTerminator
|
||||||
|
|
||||||
|
case len(token) == 1 && strings.Contains(hostTerminators, token):
|
||||||
|
return hostTokenTerminator
|
||||||
|
|
||||||
|
case len(token) == 2 && token[0] == '\\':
|
||||||
|
switch token[1] {
|
||||||
|
case 'S', 'D', 'W':
|
||||||
|
return hostTokenCrosser
|
||||||
|
case 'd', 'w', 's':
|
||||||
|
return hostTokenNeutral
|
||||||
|
case 'p', 'P':
|
||||||
|
return hostTokenCrosser
|
||||||
|
}
|
||||||
|
if strings.Contains(hostTerminators, token[1:]) {
|
||||||
|
return hostTokenTerminator
|
||||||
|
}
|
||||||
|
return hostTokenNeutral
|
||||||
|
|
||||||
|
case strings.HasPrefix(token, "["):
|
||||||
|
inner := strings.TrimSuffix(strings.TrimPrefix(token, "["), "]")
|
||||||
|
if strings.HasPrefix(inner, "^") {
|
||||||
|
// A negated class almost always admits "@".
|
||||||
|
return hostTokenCrosser
|
||||||
|
}
|
||||||
|
if classContainsAny(inner, crosserClassChars) {
|
||||||
|
return hostTokenCrosser
|
||||||
|
}
|
||||||
|
if classOnlyTerminators(inner) {
|
||||||
|
return hostTokenTerminator
|
||||||
|
}
|
||||||
|
return hostTokenNeutral
|
||||||
|
|
||||||
|
case strings.HasPrefix(token, "("):
|
||||||
|
return classifyGroup(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hostTokenNeutral
|
||||||
|
}
|
||||||
|
|
||||||
|
// classifyGroup classifies a parenthesized group. A group whose every branch
|
||||||
|
// starts with a terminator ends the authority, which is what makes the
|
||||||
|
// idiomatic "(:|/|$)" safe. A group containing a crosser is a crosser.
|
||||||
|
func classifyGroup(token string) hostTokenKind {
|
||||||
|
inner := trimInlineFlags(strings.TrimSuffix(strings.TrimPrefix(token, "("), ")"))
|
||||||
|
inner = strings.TrimPrefix(inner, "?:")
|
||||||
|
|
||||||
|
branches := splitTopLevelAlternation(inner)
|
||||||
|
|
||||||
|
allTerminate := true
|
||||||
|
for _, branch := range branches {
|
||||||
|
if branch == "" {
|
||||||
|
allTerminate = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
kind := classifyHostToken(firstToken(branch))
|
||||||
|
if kind == hostTokenCrosser {
|
||||||
|
return hostTokenCrosser
|
||||||
|
}
|
||||||
|
if kind != hostTokenTerminator {
|
||||||
|
allTerminate = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// A crosser anywhere inside the branch still escapes the authority.
|
||||||
|
if branchHasCrosser(branch) {
|
||||||
|
return hostTokenCrosser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if allTerminate {
|
||||||
|
return hostTokenTerminator
|
||||||
|
}
|
||||||
|
|
||||||
|
return hostTokenNeutral
|
||||||
|
}
|
||||||
|
|
||||||
|
// branchHasCrosser reports whether any token of branch is a crosser.
|
||||||
|
func branchHasCrosser(branch string) bool {
|
||||||
|
for i := 0; i < len(branch); {
|
||||||
|
token := tokenAt(branch, i)
|
||||||
|
if token == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if classifyHostToken(token) == hostTokenCrosser {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
i += len(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstToken returns the first regex token of s.
|
||||||
|
func firstToken(s string) string {
|
||||||
|
return tokenAt(s, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenAt returns the regex token starting at index i, or "" if it is
|
||||||
|
// malformed.
|
||||||
|
func tokenAt(s string, i int) string {
|
||||||
|
if i >= len(s) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case s[i] == '\\' && i+1 < len(s):
|
||||||
|
return s[i : i+2]
|
||||||
|
case s[i] == '[':
|
||||||
|
end := matchingBracket(s, i)
|
||||||
|
if end == -1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s[i : end+1]
|
||||||
|
case s[i] == '(':
|
||||||
|
end := matchingParen(s, i)
|
||||||
|
if end == -1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s[i : end+1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return s[i : i+1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// classOnlyTerminators reports whether every character a class can match ends
|
||||||
|
// the authority, which makes the class itself a terminator. A range is never
|
||||||
|
// treated as one.
|
||||||
|
func classOnlyTerminators(class string) bool {
|
||||||
|
if class == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(class); i++ {
|
||||||
|
if class[i] == '\\' && i+1 < len(class) {
|
||||||
|
if !strings.Contains(hostTerminators, class[i+1:i+2]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if i+2 < len(class) && class[i+1] == '-' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(hostTerminators, class[i:i+1]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// classContainsAny reports whether a character class body can match any of the
|
||||||
|
// given characters, expanding simple ranges.
|
||||||
|
func classContainsAny(class, chars string) bool {
|
||||||
|
for i := 0; i < len(class); i++ {
|
||||||
|
if class[i] == '\\' && i+1 < len(class) {
|
||||||
|
// An escape class such as \S inside a class admits everything.
|
||||||
|
if strings.ContainsRune("SDW", rune(class[i+1])) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.ContainsRune(chars, rune(class[i+1])) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if i+2 < len(class) && class[i+1] == '-' {
|
||||||
|
lo, hi := class[i], class[i+2]
|
||||||
|
for _, c := range []byte(chars) {
|
||||||
|
if c >= lo && c <= hi {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.ContainsRune(chars, rune(class[i])) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchingParen returns the index of the ")" closing the "(" at start.
|
||||||
|
func matchingParen(s string, start int) int {
|
||||||
|
depth := 0
|
||||||
|
inClass := false
|
||||||
|
|
||||||
|
for i := start; i < len(s); i++ {
|
||||||
|
switch {
|
||||||
|
case s[i] == '\\' && i+1 < len(s):
|
||||||
|
i++
|
||||||
|
case s[i] == '[' && !inClass:
|
||||||
|
inClass = true
|
||||||
|
case s[i] == ']' && inClass:
|
||||||
|
inClass = false
|
||||||
|
case s[i] == '(' && !inClass:
|
||||||
|
depth++
|
||||||
|
case s[i] == ')' && !inClass:
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchingBracket returns the index of the "]" closing the "[" at start.
|
||||||
|
func matchingBracket(s string, start int) int {
|
||||||
|
for i := start + 1; i < len(s); i++ {
|
||||||
|
switch {
|
||||||
|
case s[i] == '\\' && i+1 < len(s):
|
||||||
|
i++
|
||||||
|
case s[i] == ']':
|
||||||
|
// A "]" immediately after "[" or "[^" is a literal.
|
||||||
|
if i == start+1 || (i == start+2 && s[start+1] == '^') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// isQuantifierByte reports whether c opens a quantifier.
|
||||||
|
func isQuantifierByte(c byte) bool {
|
||||||
|
return c == '?' || c == '*' || c == '+' || c == '{'
|
||||||
|
}
|
||||||
|
|
||||||
|
// isQuantified reports whether a quantifier starts at index i.
|
||||||
|
func isQuantified(s string, i int) bool {
|
||||||
|
return i < len(s) && isQuantifierByte(s[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// isOptionalQuantifier reports whether the quantifier at index i lets the
|
||||||
|
// preceding token match nothing.
|
||||||
|
func isOptionalQuantifier(s string, i int) bool {
|
||||||
|
if i >= len(s) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch s[i] {
|
||||||
|
case '?', '*':
|
||||||
|
return true
|
||||||
|
case '{':
|
||||||
|
return strings.HasPrefix(s[i:], "{0")
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isEscapeClass reports whether c after a backslash denotes a character class
|
||||||
|
// rather than a literal.
|
||||||
|
func isEscapeClass(c byte) bool {
|
||||||
|
return strings.ContainsRune("dDwWsSbBAzZpP", rune(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
// commonPrefix returns the longest common prefix of a and b.
|
||||||
|
func commonPrefix(a, b string) string {
|
||||||
|
n := min(len(a), len(b))
|
||||||
|
for i := range n {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return a[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return a[:n]
|
||||||
|
}
|
||||||
179
pkg/gotenberg/allowlist_test.go
Normal file
179
pkg/gotenberg/allowlist_test.go
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
package gotenberg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dlclark/regexp2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAuditAllowList(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
pattern string
|
||||||
|
want AllowListRisk
|
||||||
|
}{
|
||||||
|
// Safe: the host is terminated before anything can leave it.
|
||||||
|
{"idiomatic terminator group", `^https?://internal\.svc(:|/|$)`, ""},
|
||||||
|
{"trailing slash", `^https://trusted\.example\.com/`, ""},
|
||||||
|
{"optional port then terminator", `^https://example\.com(:[0-9]+)?(/|$)`, ""},
|
||||||
|
{"positive class cannot leave authority", `^https://[a-z0-9.-]+\.s3\.amazonaws\.com/`, ""},
|
||||||
|
{"leading mandatory group", `^(https|http)://a\.example\.com/`, ""},
|
||||||
|
{"optional subdomain group", `^https://(www\.)?example\.com/`, ""},
|
||||||
|
{"port terminator", `^https://example\.com:8443/`, ""},
|
||||||
|
{"end anchor", `^https://example\.com$`, ""},
|
||||||
|
{"alternation both anchored and terminated", `^https://a\.example/|^https://b\.example/`, ""},
|
||||||
|
{"no authority to check", `^file:///tmp/`, ""},
|
||||||
|
{"digit class in host", `^https://node\d+\.example\.com/`, ""},
|
||||||
|
{"class of only terminators", `^https://example\.com[:/]`, ""},
|
||||||
|
{"feature file pattern, fixed", `^https?://host\.docker\.internal(:[0-9]+)?/`, ""},
|
||||||
|
|
||||||
|
// Unanchored: regexp2 searches, so these match anywhere in the URL.
|
||||||
|
{"no anchor", `trusted\.example\.com`, AllowListRiskUnanchored},
|
||||||
|
{"no anchor with scheme", `https://trusted\.example\.com/`, AllowListRiskUnanchored},
|
||||||
|
|
||||||
|
// Only the first branch anchored.
|
||||||
|
{"second branch unanchored", `^http://a\.example/|http://b\.example/`, AllowListRiskUnanchoredBranch},
|
||||||
|
|
||||||
|
// Catch-all: matches every URL.
|
||||||
|
{"dot plus", `.+`, AllowListRiskCatchAll},
|
||||||
|
{"dot star", `.*`, AllowListRiskCatchAll},
|
||||||
|
{"anchored dot star", `^.*`, AllowListRiskCatchAll},
|
||||||
|
{"anchored dot plus", `^.+`, AllowListRiskCatchAll},
|
||||||
|
|
||||||
|
// Open host: the reported vulnerability class.
|
||||||
|
{"advisory pattern", `^http://trusted\.example\.com`, AllowListRiskOpenHost},
|
||||||
|
{"gotenberg.dev internet-facing recipe", `^https?://[^/]+\.internal\.example\.com`, AllowListRiskOpenHost},
|
||||||
|
{"gotenberg.dev strict whitelist recipe", `^https://(api|cdn|images)\.internal\.example\.com`, AllowListRiskOpenHost},
|
||||||
|
{"gotenberg.dev hooks recipe", `^https?://hooks\.internal\.example\.com`, AllowListRiskOpenHost},
|
||||||
|
{"feature file pattern", `^https?://host.docker.internal.*`, AllowListRiskOpenHost},
|
||||||
|
{"scheme only", `^https?://`, AllowListRiskOpenHost},
|
||||||
|
{"wildcard subdomain", `^https://.+\.example\.com/`, AllowListRiskOpenHost},
|
||||||
|
{"escaped dot is not a terminator", `^https?://example\.com\.`, AllowListRiskOpenHost},
|
||||||
|
{"negated class in host", `^https://[^.]+\.example\.com/`, AllowListRiskOpenHost},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
findings := AuditAllowList([]string{tc.pattern})
|
||||||
|
|
||||||
|
if tc.want == "" {
|
||||||
|
if len(findings) != 0 {
|
||||||
|
t.Fatalf("AuditAllowList(%q) = %+v, want no finding", tc.pattern, findings)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(findings) != 1 {
|
||||||
|
t.Fatalf("AuditAllowList(%q) returned %d findings, want 1", tc.pattern, len(findings))
|
||||||
|
}
|
||||||
|
if findings[0].Risk != tc.want {
|
||||||
|
t.Fatalf("AuditAllowList(%q) risk = %q, want %q", tc.pattern, findings[0].Risk, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuditAllowList_FlaggedPatternsAreActuallyExploitable proves the audit is
|
||||||
|
// not merely syntactic: every pattern it flags as open-host really does admit
|
||||||
|
// a host the operator did not intend.
|
||||||
|
func TestAuditAllowList_FlaggedPatternsAreActuallyExploitable(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
pattern string
|
||||||
|
attack string
|
||||||
|
}{
|
||||||
|
{`^http://trusted\.example\.com`, "http://trusted.example.com.attacker.example/"},
|
||||||
|
{`^https?://[^/]+\.internal\.example\.com`, "http://a.internal.example.com.attacker.example/"},
|
||||||
|
{`^https://(api|cdn|images)\.internal\.example\.com`, "https://api.internal.example.com.attacker.example/"},
|
||||||
|
{`^https?://hooks\.internal\.example\.com`, "http://hooks.internal.example.com.attacker.example/"},
|
||||||
|
{`^https?://host.docker.internal.*`, "http://host.docker.internal.attacker.example/"},
|
||||||
|
{`^https://.+\.example\.com/`, "https://attacker.example/#x.example.com/"},
|
||||||
|
{`^https?://example\.com\.`, "http://example.com.attacker.example/"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.pattern, func(t *testing.T) {
|
||||||
|
findings := AuditAllowList([]string{tc.pattern})
|
||||||
|
if len(findings) == 0 {
|
||||||
|
t.Fatalf("pattern %q was not flagged", tc.pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := regexp2.MustCompile(tc.pattern, 0).MatchString(tc.attack)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("match %q: %v", tc.attack, err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("pattern %q does not match %q, so the finding is a false positive", tc.pattern, tc.attack)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuditAllowList_SafePatternsRejectTheAttacks is the converse: the shapes
|
||||||
|
// the audit stays silent about really do reject the same attacks.
|
||||||
|
func TestAuditAllowList_SafePatternsRejectTheAttacks(t *testing.T) {
|
||||||
|
safe := []string{
|
||||||
|
`^https?://internal\.svc(:|/|$)`,
|
||||||
|
`^https://trusted\.example\.com/`,
|
||||||
|
`^https://example\.com(:[0-9]+)?(/|$)`,
|
||||||
|
`^https://[a-z0-9.-]+\.s3\.amazonaws\.com/`,
|
||||||
|
}
|
||||||
|
attacks := []string{
|
||||||
|
"https://internal.svc.attacker.example/",
|
||||||
|
"https://trusted.example.com.attacker.example/",
|
||||||
|
"https://trusted.example.com@169.254.169.254/",
|
||||||
|
"https://example.com.attacker.example/",
|
||||||
|
"https://example.com@10.0.0.5/",
|
||||||
|
"https://bucket.s3.amazonaws.com.attacker.example/",
|
||||||
|
"https://bucket.s3.amazonaws.com@127.0.0.1/",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pattern := range safe {
|
||||||
|
t.Run(pattern, func(t *testing.T) {
|
||||||
|
if findings := AuditAllowList([]string{pattern}); len(findings) != 0 {
|
||||||
|
t.Fatalf("safe pattern %q was flagged as %q", pattern, findings[0].Risk)
|
||||||
|
}
|
||||||
|
|
||||||
|
re := regexp2.MustCompile(pattern, 0)
|
||||||
|
for _, attack := range attacks {
|
||||||
|
ok, err := re.MatchString(attack)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("match %q: %v", attack, err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("pattern %q matches attack %q but was not flagged", pattern, attack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuditAllowList_SkipsEmptyAndOversized(t *testing.T) {
|
||||||
|
oversized := make([]byte, maxAuditedPatternLength+1)
|
||||||
|
for i := range oversized {
|
||||||
|
oversized[i] = 'a'
|
||||||
|
}
|
||||||
|
|
||||||
|
findings := AuditAllowList([]string{"", string(oversized)})
|
||||||
|
if len(findings) != 0 {
|
||||||
|
t.Fatalf("AuditAllowList returned %+v, want no finding", findings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuditAllowList_ReportsIndex(t *testing.T) {
|
||||||
|
findings := AuditAllowList([]string{
|
||||||
|
`^https://ok\.example\.com/`,
|
||||||
|
`^https://open\.example\.com`,
|
||||||
|
})
|
||||||
|
if len(findings) != 1 {
|
||||||
|
t.Fatalf("got %d findings, want 1", len(findings))
|
||||||
|
}
|
||||||
|
if findings[0].Index != 1 {
|
||||||
|
t.Fatalf("findings[0].Index = %d, want 1", findings[0].Index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuditAllowList_ShippedChromiumDenyListIsNotAudited guards the rule that
|
||||||
|
// deny-lists are never audited. The shipped Chromium deny-list uses a
|
||||||
|
// lookaround and has no authority, so auditing it would produce noise.
|
||||||
|
func TestAuditAllowList_LookaroundIsNotFlaggedForHost(t *testing.T) {
|
||||||
|
findings := AuditAllowList([]string{`^file:(?!//\/tmp/).*`})
|
||||||
|
if len(findings) != 0 {
|
||||||
|
t.Fatalf("AuditAllowList returned %+v, want no finding", findings)
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
package gotenberg
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/dlclark/regexp2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrFiltered happens if a value is filtered by the [FilterDeadline] function.
|
|
||||||
var ErrFiltered = errors.New("value filtered")
|
|
||||||
|
|
||||||
// FilterDeadline checks if the given value is allowed and not denied according
|
|
||||||
// to regex patterns. The allowed list uses OR semantics (value must match at
|
|
||||||
// least one pattern). The denied list uses OR semantics (value is denied if it
|
|
||||||
// matches any pattern). It returns a [context.DeadlineExceeded] if it takes
|
|
||||||
// too long to process.
|
|
||||||
func FilterDeadline(allowed, denied []*regexp2.Regexp, s string, deadline time.Time) error {
|
|
||||||
if len(allowed) > 0 {
|
|
||||||
matched := false
|
|
||||||
|
|
||||||
for _, pattern := range allowed {
|
|
||||||
// FIXME: not ideal to compile everytime, but is there another way to create a clone?
|
|
||||||
clone := regexp2.MustCompile(pattern.String(), 0)
|
|
||||||
clone.MatchTimeout = time.Until(deadline)
|
|
||||||
|
|
||||||
ok, err := clone.MatchString(s)
|
|
||||||
if err != nil {
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
return context.DeadlineExceeded
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), s, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ok {
|
|
||||||
matched = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !matched {
|
|
||||||
return fmt.Errorf("'%s' does not match any expression from the allowed list: %w", s, ErrFiltered)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(denied) > 0 {
|
|
||||||
for _, pattern := range denied {
|
|
||||||
clone := regexp2.MustCompile(pattern.String(), 0)
|
|
||||||
clone.MatchTimeout = time.Until(deadline)
|
|
||||||
|
|
||||||
ok, err := clone.MatchString(s)
|
|
||||||
if err != nil {
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
return context.DeadlineExceeded
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), s, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ok {
|
|
||||||
return fmt.Errorf("'%s' matches the expression from the denied list: %w", s, ErrFiltered)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
package gotenberg
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/dlclark/regexp2"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestFilterDeadline(t *testing.T) {
|
|
||||||
for _, tc := range []struct {
|
|
||||||
scenario string
|
|
||||||
allowed []*regexp2.Regexp
|
|
||||||
denied []*regexp2.Regexp
|
|
||||||
s string
|
|
||||||
deadline time.Time
|
|
||||||
expectError bool
|
|
||||||
expectedError error
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
scenario: "DeadlineExceeded (allowed)",
|
|
||||||
allowed: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)},
|
|
||||||
denied: nil,
|
|
||||||
s: "foo",
|
|
||||||
deadline: time.Now().Add(time.Duration(-1) * time.Hour),
|
|
||||||
expectError: true,
|
|
||||||
expectedError: context.DeadlineExceeded,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scenario: "ErrFiltered (allowed, no match)",
|
|
||||||
allowed: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)},
|
|
||||||
denied: nil,
|
|
||||||
s: "bar",
|
|
||||||
deadline: time.Now().Add(time.Duration(5) * time.Second),
|
|
||||||
expectError: true,
|
|
||||||
expectedError: ErrFiltered,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scenario: "DeadlineExceeded (denied)",
|
|
||||||
allowed: nil,
|
|
||||||
denied: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)},
|
|
||||||
s: "foo",
|
|
||||||
deadline: time.Now().Add(time.Duration(-1) * time.Hour),
|
|
||||||
expectError: true,
|
|
||||||
expectedError: context.DeadlineExceeded,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scenario: "ErrFiltered (denied)",
|
|
||||||
allowed: nil,
|
|
||||||
denied: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)},
|
|
||||||
s: "foo",
|
|
||||||
deadline: time.Now().Add(time.Duration(5) * time.Second),
|
|
||||||
expectError: true,
|
|
||||||
expectedError: ErrFiltered,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scenario: "success (empty lists)",
|
|
||||||
allowed: nil,
|
|
||||||
denied: nil,
|
|
||||||
s: "foo",
|
|
||||||
deadline: time.Now().Add(time.Duration(5) * time.Second),
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scenario: "multi-pattern allow list, second matches",
|
|
||||||
allowed: []*regexp2.Regexp{regexp2.MustCompile("^https://", 0), regexp2.MustCompile("^file:///tmp/", 0)},
|
|
||||||
denied: nil,
|
|
||||||
s: "file:///tmp/abc/index.html",
|
|
||||||
deadline: time.Now().Add(time.Duration(5) * time.Second),
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scenario: "multi-pattern allow list, none matches",
|
|
||||||
allowed: []*regexp2.Regexp{regexp2.MustCompile("^https://", 0), regexp2.MustCompile("^ftp://", 0)},
|
|
||||||
denied: nil,
|
|
||||||
s: "file:///tmp/abc/index.html",
|
|
||||||
deadline: time.Now().Add(time.Duration(5) * time.Second),
|
|
||||||
expectError: true,
|
|
||||||
expectedError: ErrFiltered,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scenario: "multi-pattern deny list, second matches",
|
|
||||||
allowed: nil,
|
|
||||||
denied: []*regexp2.Regexp{regexp2.MustCompile("^ftp://", 0), regexp2.MustCompile("^file:.*", 0)},
|
|
||||||
s: "file:///etc/passwd",
|
|
||||||
deadline: time.Now().Add(time.Duration(5) * time.Second),
|
|
||||||
expectError: true,
|
|
||||||
expectedError: ErrFiltered,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scenario: "https URL passes deny list targeting file://",
|
|
||||||
allowed: nil,
|
|
||||||
denied: []*regexp2.Regexp{regexp2.MustCompile("^file:.*", 0)},
|
|
||||||
s: "https://example.com",
|
|
||||||
deadline: time.Now().Add(time.Duration(5) * time.Second),
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tc.scenario, func(t *testing.T) {
|
|
||||||
err := FilterDeadline(tc.allowed, tc.denied, tc.s, tc.deadline)
|
|
||||||
|
|
||||||
if tc.expectError && err == nil {
|
|
||||||
t.Fatal("expected an error but got none")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !tc.expectError && err != nil {
|
|
||||||
t.Fatalf("expected no error but got: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
|
|
||||||
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
package gotenberg
|
package gotenberg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dlclark/regexp2"
|
"github.com/dlclark/regexp2"
|
||||||
"github.com/labstack/gommon/bytes"
|
"github.com/labstack/gommon/bytes"
|
||||||
flag "github.com/spf13/pflag"
|
flag "github.com/spf13/pflag"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ParsedFlags wraps a [flag.FlagSet] so that retrieving the typed values is
|
// ParsedFlags wraps a [flag.FlagSet] so that retrieving the typed values is
|
||||||
@@ -201,15 +207,38 @@ func (f *ParsedFlags) MustDeprecatedHumanReadableBytes(deprecated string, newNam
|
|||||||
return f.MustHumanReadableBytes(newName)
|
return f.MustHumanReadableBytes(newName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PatternMatchTimeout bounds a single match against an operator-supplied
|
||||||
|
// allow-list or deny-list pattern.
|
||||||
|
//
|
||||||
|
// regexp2 backtracks, and the strings matched against these patterns are
|
||||||
|
// client-controlled: a request URL, a CONNECT host. A pattern that backtracks
|
||||||
|
// catastrophically would otherwise burn a core for as long as the caller's
|
||||||
|
// deadline allows, which is --api-timeout (env API_TIMEOUT), 30 seconds by
|
||||||
|
// default. The ceiling mirrors the one the Chromium module already applies to
|
||||||
|
// the per-request extraHttpHeaders scope pattern.
|
||||||
|
//
|
||||||
|
// [ParsedFlags.MustRegexp] and [ParsedFlags.MustRegexpSlice] stamp this onto
|
||||||
|
// every pattern they compile, which is how all four production lists are
|
||||||
|
// built. Patterns compiled any other way keep regexp2's default of
|
||||||
|
// math.MaxInt64, which it treats as no timeout at all, so a hand-built slice
|
||||||
|
// must set this itself before reaching [DecideOutbound].
|
||||||
|
const PatternMatchTimeout = 250 * time.Millisecond
|
||||||
|
|
||||||
// MustRegexp returns the regular expression of a flag given by name.
|
// MustRegexp returns the regular expression of a flag given by name.
|
||||||
// It panics if an error occurs.
|
// It panics if an error occurs.
|
||||||
|
//
|
||||||
|
// The returned expression carries [PatternMatchTimeout] and is safe to match
|
||||||
|
// on concurrently: callers must not compile a private copy per match.
|
||||||
func (f *ParsedFlags) MustRegexp(name string) *regexp2.Regexp {
|
func (f *ParsedFlags) MustRegexp(name string) *regexp2.Regexp {
|
||||||
val, err := f.GetString(name)
|
val, err := f.GetString(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return regexp2.MustCompile(val, 0)
|
re := regexp2.MustCompile(val, 0)
|
||||||
|
re.MatchTimeout = PatternMatchTimeout
|
||||||
|
|
||||||
|
return re
|
||||||
}
|
}
|
||||||
|
|
||||||
// MustDeprecatedRegexp returns the regular expression of a deprecated flag if
|
// MustDeprecatedRegexp returns the regular expression of a deprecated flag if
|
||||||
@@ -226,21 +255,140 @@ func (f *ParsedFlags) MustDeprecatedRegexp(deprecated string, newName string) *r
|
|||||||
// MustRegexpSlice returns a slice of compiled regular expressions from a
|
// MustRegexpSlice returns a slice of compiled regular expressions from a
|
||||||
// string-slice flag given by name. Empty strings are skipped.
|
// string-slice flag given by name. Empty strings are skipped.
|
||||||
// It panics if an error occurs.
|
// It panics if an error occurs.
|
||||||
|
//
|
||||||
|
// Every allow-list and deny-list in Gotenberg is read through this method, so
|
||||||
|
// it is also where allow-list patterns are audited. See [AuditAllowList].
|
||||||
|
//
|
||||||
|
// The returned expressions carry [PatternMatchTimeout] and are safe to match
|
||||||
|
// on concurrently: callers must not compile a private copy per match.
|
||||||
func (f *ParsedFlags) MustRegexpSlice(name string) []*regexp2.Regexp {
|
func (f *ParsedFlags) MustRegexpSlice(name string) []*regexp2.Regexp {
|
||||||
vals := f.MustStringSlice(name)
|
vals := f.MustStringSlice(name)
|
||||||
|
|
||||||
|
f.warnRiskyAllowList(name, vals)
|
||||||
|
|
||||||
var regexps []*regexp2.Regexp
|
var regexps []*regexp2.Regexp
|
||||||
for _, val := range vals {
|
for _, val := range vals {
|
||||||
if val == "" {
|
if val == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
regexps = append(regexps, regexp2.MustCompile(val, 0))
|
re := regexp2.MustCompile(val, 0)
|
||||||
|
re.MatchTimeout = PatternMatchTimeout
|
||||||
|
|
||||||
|
regexps = append(regexps, re)
|
||||||
}
|
}
|
||||||
|
|
||||||
return regexps
|
return regexps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// allowListFlagSuffix identifies the flags whose patterns grant an IP-check
|
||||||
|
// bypass. Deny-lists are never audited: they always apply, cannot be bypassed,
|
||||||
|
// and a loose deny-list is safe rather than dangerous.
|
||||||
|
const allowListFlagSuffix = "-allow-list"
|
||||||
|
|
||||||
|
// warnRiskyAllowList logs one warning per allow-list entry that matches more
|
||||||
|
// URLs than its author is likely to intend.
|
||||||
|
//
|
||||||
|
// It warns and never fails: operators depend on loose patterns today, and
|
||||||
|
// rejecting them at startup would break running deployments.
|
||||||
|
func (f *ParsedFlags) warnRiskyAllowList(name string, vals []string) {
|
||||||
|
if !strings.HasSuffix(name, allowListFlagSuffix) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
findings := AuditAllowList(vals)
|
||||||
|
if len(findings) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The logger is nil until the entry point initializes it, which happens
|
||||||
|
// before any module is provisioned. Tests and embedders that call this
|
||||||
|
// method directly get no logger, and must not panic for it.
|
||||||
|
logger := log.Logger()
|
||||||
|
if logger == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, finding := range findings {
|
||||||
|
// Provision has no context.Context to propagate, so the trace-aware
|
||||||
|
// logging convention is satisfied with a background context.
|
||||||
|
logger.WarnContext(
|
||||||
|
context.Background(),
|
||||||
|
f.allowListWarning(name, finding),
|
||||||
|
slog.String("flag", "--"+name),
|
||||||
|
slog.String("env", EnvVarName(name)),
|
||||||
|
slog.Int("entry", finding.Index+1),
|
||||||
|
slog.String("reason", string(finding.Risk)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowListWarning builds the operator-facing message for a finding. It names
|
||||||
|
// the flag and its environment variable, and, when they exist, the IP-check
|
||||||
|
// flags the entry silently disables.
|
||||||
|
func (f *ParsedFlags) allowListWarning(name string, finding AllowListFinding) string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
// Print the pattern raw rather than quoted: %q escapes every backslash, so
|
||||||
|
// the operator would not recognize the value they set.
|
||||||
|
fmt.Fprintf(&b, "--%s (%s) entry %d '%s' ", name, EnvVarName(name), finding.Index+1, finding.Pattern)
|
||||||
|
|
||||||
|
switch finding.Risk {
|
||||||
|
case AllowListRiskUnanchored:
|
||||||
|
b.WriteString("is not anchored with ^, so it matches anywhere in the URL and a URL such as http://attacker.example/?u=trusted.example.com passes. ")
|
||||||
|
case AllowListRiskUnanchoredBranch:
|
||||||
|
b.WriteString("has an alternation branch that is not anchored with ^, and that branch matches anywhere in the URL. ")
|
||||||
|
case AllowListRiskCatchAll:
|
||||||
|
b.WriteString("matches every URL. ")
|
||||||
|
case AllowListRiskOpenHost:
|
||||||
|
b.WriteString("does not terminate the host, so it also matches suffix hosts such as http://trusted.example.com.attacker.example/. ")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(f.bypassSentence(name))
|
||||||
|
|
||||||
|
switch finding.Risk {
|
||||||
|
case AllowListRiskUnanchored, AllowListRiskUnanchoredBranch:
|
||||||
|
b.WriteString("Anchor every branch with ^ and end the host with /, :, or $.")
|
||||||
|
case AllowListRiskCatchAll:
|
||||||
|
b.WriteString("Restrict the entry to the hosts you trust, or unset the flag.")
|
||||||
|
case AllowListRiskOpenHost:
|
||||||
|
b.WriteString("End the host with /, :, $, or a group such as (:|/|$).")
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// bypassSentence names the IP-check flags an allow-list match skips, when the
|
||||||
|
// module registers them.
|
||||||
|
func (f *ParsedFlags) bypassSentence(name string) string {
|
||||||
|
prefix := strings.TrimSuffix(name, allowListFlagSuffix)
|
||||||
|
|
||||||
|
private, public := prefix+"-deny-private-ips", prefix+"-deny-public-ips"
|
||||||
|
if f.Lookup(private) == nil || f.Lookup(public) == nil {
|
||||||
|
// A deprecated alias such as webhook-error-allow-list carries an extra
|
||||||
|
// segment that the IP-check flags do not have.
|
||||||
|
if i := strings.LastIndex(prefix, "-"); i != -1 {
|
||||||
|
private, public = prefix[:i]+"-deny-private-ips", prefix[:i]+"-deny-public-ips"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.Lookup(private) == nil || f.Lookup(public) == nil {
|
||||||
|
return "A URL that matches the allow-list skips the private and public IP checks. "
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"A URL that matches the allow-list skips --%s (%s) and --%s (%s). ",
|
||||||
|
private, EnvVarName(private), public, EnvVarName(public),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnvVarName returns the environment variable that overrides the flag given by
|
||||||
|
// name. The entry point derives the same name when it applies environment
|
||||||
|
// overrides, so operator-facing messages can name both without drifting.
|
||||||
|
func EnvVarName(name string) string {
|
||||||
|
return strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
|
||||||
|
}
|
||||||
|
|
||||||
// MustDeprecatedRegexpSlice returns the slice of compiled regular expressions
|
// MustDeprecatedRegexpSlice returns the slice of compiled regular expressions
|
||||||
// of a deprecated flag if it was explicitly set or the slice of the new flag.
|
// of a deprecated flag if it was explicitly set or the slice of the new flag.
|
||||||
// It panics if an error occurs.
|
// It panics if an error occurs.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package gotenberg
|
|||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -951,3 +952,138 @@ func TestParsedFlags_MustDeprecatedRegexpSlice(t *testing.T) {
|
|||||||
|
|
||||||
_ = regexp2.None // Keep import alive.
|
_ = regexp2.None // Keep import alive.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParsedFlags_AllowListWarning(t *testing.T) {
|
||||||
|
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||||
|
fs.StringSlice("chromium-allow-list", []string{}, "")
|
||||||
|
fs.Bool("chromium-deny-private-ips", false, "")
|
||||||
|
fs.Bool("chromium-deny-public-ips", false, "")
|
||||||
|
fs.StringSlice("standalone-allow-list", []string{}, "")
|
||||||
|
|
||||||
|
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
name string
|
||||||
|
finding AllowListFinding
|
||||||
|
contains []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "open host names both IP-check flags and their env vars",
|
||||||
|
name: "chromium-allow-list",
|
||||||
|
finding: AllowListFinding{Index: 0, Pattern: `^https://trusted\.example\.com`, Risk: AllowListRiskOpenHost},
|
||||||
|
contains: []string{
|
||||||
|
"--chromium-allow-list (CHROMIUM_ALLOW_LIST)",
|
||||||
|
"entry 1",
|
||||||
|
`^https://trusted\.example\.com`,
|
||||||
|
"does not terminate the host",
|
||||||
|
"--chromium-deny-private-ips (CHROMIUM_DENY_PRIVATE_IPS)",
|
||||||
|
"--chromium-deny-public-ips (CHROMIUM_DENY_PUBLIC_IPS)",
|
||||||
|
"End the host with",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "catch-all tells the operator to restrict or unset",
|
||||||
|
name: "chromium-allow-list",
|
||||||
|
finding: AllowListFinding{Index: 2, Pattern: ".+", Risk: AllowListRiskCatchAll},
|
||||||
|
contains: []string{"entry 3", "matches every URL", "Restrict the entry"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "unanchored explains the search semantics",
|
||||||
|
name: "chromium-allow-list",
|
||||||
|
finding: AllowListFinding{Index: 0, Pattern: `trusted\.example\.com`, Risk: AllowListRiskUnanchored},
|
||||||
|
contains: []string{"is not anchored with ^", "Anchor every branch with ^"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "module without IP-check flags falls back to a generic sentence",
|
||||||
|
name: "standalone-allow-list",
|
||||||
|
finding: AllowListFinding{Index: 0, Pattern: `^https://a\.example\.com`, Risk: AllowListRiskOpenHost},
|
||||||
|
contains: []string{"skips the private and public IP checks"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
msg := parsedFlags.allowListWarning(tc.name, tc.finding)
|
||||||
|
|
||||||
|
for _, want := range tc.contains {
|
||||||
|
if !strings.Contains(msg, want) {
|
||||||
|
t.Fatalf("message %q does not contain %q", msg, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(msg, "—") {
|
||||||
|
t.Fatalf("message must not contain an em dash: %q", msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsedFlags_WarnRiskyAllowList_SkipsDenyLists(t *testing.T) {
|
||||||
|
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||||
|
fs.StringSlice("chromium-deny-list", []string{}, "")
|
||||||
|
|
||||||
|
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||||
|
|
||||||
|
// A deny-list is never audited: it always applies and cannot be bypassed,
|
||||||
|
// so a loose one is safe. This must also not panic on a nil logger.
|
||||||
|
parsedFlags.warnRiskyAllowList("chromium-deny-list", []string{".+", `^file:(?!//\/tmp/).*`})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsedFlags_WarnRiskyAllowList_NilLoggerDoesNotPanic(t *testing.T) {
|
||||||
|
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||||
|
fs.StringSlice("chromium-allow-list", []string{}, "")
|
||||||
|
|
||||||
|
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||||
|
|
||||||
|
// Provision runs after the entry point initializes the logger, but tests
|
||||||
|
// and embedders reach this path with no logger at all.
|
||||||
|
parsedFlags.warnRiskyAllowList("chromium-allow-list", []string{".+"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvVarName(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"chromium-allow-list", "CHROMIUM_ALLOW_LIST"},
|
||||||
|
{"api-download-from-deny-private-ips", "API_DOWNLOAD_FROM_DENY_PRIVATE_IPS"},
|
||||||
|
{"log-level", "LOG_LEVEL"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := EnvVarName(tc.name); got != tc.want {
|
||||||
|
t.Fatalf("EnvVarName(%q) = %q, want %q", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsedFlags_RegexpMatchTimeout(t *testing.T) {
|
||||||
|
// [DecideOutbound] matches on these patterns directly instead of compiling
|
||||||
|
// a private copy per call, so the bound has to come from here. regexp2's
|
||||||
|
// own default is math.MaxInt64, which it treats as no
|
||||||
|
// timeout at all, so a pattern built without this stamp runs unbounded
|
||||||
|
// against a client-controlled string.
|
||||||
|
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||||
|
fs.StringSlice("some-deny-list", []string{`^file:`, `^https?://`}, "")
|
||||||
|
fs.String("some-pattern", `^file:`, "")
|
||||||
|
|
||||||
|
err := fs.Parse(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||||
|
|
||||||
|
regexps := parsedFlags.MustRegexpSlice("some-deny-list")
|
||||||
|
if len(regexps) != 2 {
|
||||||
|
t.Fatalf("expected 2 patterns but got %d", len(regexps))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, re := range regexps {
|
||||||
|
if re.MatchTimeout != PatternMatchTimeout {
|
||||||
|
t.Fatalf("pattern '%s' has MatchTimeout %s, expected %s", re.String(), re.MatchTimeout, PatternMatchTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := parsedFlags.MustRegexp("some-pattern").MatchTimeout; got != PatternMatchTimeout {
|
||||||
|
t.Fatalf("expected MustRegexp MatchTimeout %s but got %s", PatternMatchTimeout, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||||
@@ -14,45 +15,104 @@ import (
|
|||||||
"go.opentelemetry.io/otel/propagation"
|
"go.opentelemetry.io/otel/propagation"
|
||||||
"go.opentelemetry.io/otel/sdk/log"
|
"go.opentelemetry.io/otel/sdk/log"
|
||||||
"go.opentelemetry.io/otel/sdk/metric"
|
"go.opentelemetry.io/otel/sdk/metric"
|
||||||
|
"go.opentelemetry.io/otel/sdk/metric/exemplar"
|
||||||
"go.opentelemetry.io/otel/sdk/resource"
|
"go.opentelemetry.io/otel/sdk/resource"
|
||||||
"go.opentelemetry.io/otel/sdk/trace"
|
"go.opentelemetry.io/otel/sdk/trace"
|
||||||
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
|
semconv "go.opentelemetry.io/otel/semconv/v1.43.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.
|
||||||
|
//
|
||||||
|
// The semconv version imported here must match the one the SDK resource
|
||||||
|
// detectors use (go.opentelemetry.io/otel/sdk/resource). Drift makes
|
||||||
|
// [resource.Merge] fail with [resource.ErrSchemaURLConflict] and strips the
|
||||||
|
// schema URL off every exported signal.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// A schema URL conflict still yields a resource holding every attribute, only
|
||||||
|
// without a schema URL. Keep it: falling back to base would drop the host,
|
||||||
|
// OS, container, process, and OTEL_RESOURCE_ATTRIBUTES data.
|
||||||
|
merged, err := resource.Merge(detected, base)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnContext(ctx, fmt.Sprintf("merge OpenTelemetry resource: %s", err))
|
||||||
|
}
|
||||||
|
if merged == nil {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// OTEL_*_EXPORTER select the exporter for each signal. autoexport treats an
|
||||||
|
// unset or empty value as a request for the OTLP exporter, which then fails
|
||||||
|
// against the default localhost:4318 endpoint when nothing listens there and,
|
||||||
|
// for metrics, keeps retrying on the periodic reader's timer. Gotenberg keeps
|
||||||
|
// telemetry opt-in: a signal with no exporter configured is built without one
|
||||||
|
// and stays inert. See https://github.com/gotenberg/gotenberg/issues/1643.
|
||||||
|
const (
|
||||||
|
tracesExporterEnvKey = "OTEL_TRACES_EXPORTER"
|
||||||
|
metricsExporterEnvKey = "OTEL_METRICS_EXPORTER"
|
||||||
|
logsExporterEnvKey = "OTEL_LOGS_EXPORTER"
|
||||||
|
)
|
||||||
|
|
||||||
|
// exporterConfigured reports whether the operator selected an exporter for the
|
||||||
|
// signal owning envKey. An unset or blank value keeps that signal off.
|
||||||
|
func exporterConfigured(envKey string) bool {
|
||||||
|
return strings.TrimSpace(os.Getenv(envKey)) != ""
|
||||||
|
}
|
||||||
|
|
||||||
// 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),
|
||||||
}
|
}
|
||||||
|
|
||||||
traceExporter, err := autoexport.NewSpanExporter(ctx)
|
if exporterConfigured(tracesExporterEnvKey) {
|
||||||
if err != nil {
|
traceExporter, err := autoexport.NewSpanExporter(ctx)
|
||||||
return nil, err
|
if err != nil {
|
||||||
}
|
return nil, err
|
||||||
|
}
|
||||||
if !autoexport.IsNoneSpanExporter(traceExporter) {
|
if !autoexport.IsNoneSpanExporter(traceExporter) {
|
||||||
traceOpts = append(traceOpts, trace.WithBatcher(traceExporter))
|
traceOpts = append(traceOpts, trace.WithBatcher(traceExporter))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
traceProvider := trace.NewTracerProvider(traceOpts...)
|
traceProvider := trace.NewTracerProvider(traceOpts...)
|
||||||
@@ -71,35 +131,22 @@ 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)
|
if exporterConfigured(metricsExporterEnvKey) {
|
||||||
if err != nil {
|
metricReader, err := autoexport.NewMetricReader(ctx)
|
||||||
return nil, err
|
if err != nil {
|
||||||
}
|
return nil, err
|
||||||
|
}
|
||||||
if !autoexport.IsNoneMetricReader(metricReader) {
|
if !autoexport.IsNoneMetricReader(metricReader) {
|
||||||
metricOpts = append(metricOpts, metric.WithReader(metricReader))
|
metricOpts = append(metricOpts, metric.WithReader(metricReader))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
meterProvider := metric.NewMeterProvider(metricOpts...)
|
meterProvider := metric.NewMeterProvider(metricOpts...)
|
||||||
@@ -108,40 +155,37 @@ 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),
|
||||||
}
|
}
|
||||||
|
|
||||||
logExporter, err := autoexport.NewLogExporter(ctx)
|
if exporterConfigured(logsExporterEnvKey) {
|
||||||
if err != nil {
|
logExporter, err := autoexport.NewLogExporter(ctx)
|
||||||
return nil, nil, err
|
if err != nil {
|
||||||
}
|
return nil, nil, err
|
||||||
|
}
|
||||||
if !autoexport.IsNoneLogExporter(logExporter) {
|
if !autoexport.IsNoneLogExporter(logExporter) {
|
||||||
logOpts = append(logOpts, log.WithProcessor(log.NewBatchProcessor(logExporter)))
|
logOpts = append(logOpts, log.WithProcessor(log.NewBatchProcessor(logExporter)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loggerProvider := log.NewLoggerProvider(logOpts...)
|
loggerProvider := log.NewLoggerProvider(logOpts...)
|
||||||
|
|||||||
201
pkg/gotenberg/internal/otel/otel_test.go
Normal file
201
pkg/gotenberg/internal/otel/otel_test.go
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
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.43.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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guards the semconv version pinned in buildResource against the one the SDK
|
||||||
|
// resource detectors use. Drift makes resource.Merge conflict and drops the
|
||||||
|
// schema URL from every exported signal.
|
||||||
|
if res.SchemaURL() != semconv.SchemaURL {
|
||||||
|
t.Errorf("resource schema URL = %q, want %q", res.SchemaURL(), semconv.SchemaURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExporterConfigured pins the opt-in gate: an unset or blank
|
||||||
|
// OTEL_*_EXPORTER keeps the signal off, so Gotenberg never wires the OTLP
|
||||||
|
// exporter that autoexport would otherwise default to and fail to reach at
|
||||||
|
// localhost:4318. See https://github.com/gotenberg/gotenberg/issues/1643.
|
||||||
|
func TestExporterConfigured(t *testing.T) {
|
||||||
|
const key = "OTEL_METRICS_EXPORTER"
|
||||||
|
|
||||||
|
orig, had := os.LookupEnv(key)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if had {
|
||||||
|
os.Setenv(key, orig)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
os.Unsetenv(key)
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
unset bool
|
||||||
|
value string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"unset", true, "", false},
|
||||||
|
{"empty", false, "", false},
|
||||||
|
{"whitespace only", false, " ", false},
|
||||||
|
{"none", false, "none", true},
|
||||||
|
{"otlp", false, "otlp", true},
|
||||||
|
{"padded value", false, " otlp ", true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
if tc.unset {
|
||||||
|
os.Unsetenv(key)
|
||||||
|
} else {
|
||||||
|
os.Setenv(key, tc.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := exporterConfigured(key); got != tc.want {
|
||||||
|
t.Errorf("exporterConfigured(%q) = %v, want %v", key, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,24 @@ 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)
|
OptimizeImagesMock func(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error
|
||||||
PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
|
ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
|
||||||
WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
|
PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
|
||||||
ReadBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error)
|
WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
|
||||||
EncryptMock func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
|
ReadBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error)
|
||||||
EmbedFilesMock func(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
|
EncryptMock func(ctx context.Context, logger *slog.Logger, inputPath string, opts EncryptOptions) error
|
||||||
EmbedFilesMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error
|
EmbedFilesMock func(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
|
||||||
WriteBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
|
EmbedFilesMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error
|
||||||
WatermarkMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
|
WriteBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
|
||||||
StampMock 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
|
||||||
RotateMock func(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
|
StampMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
|
||||||
|
RotateMock func(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
|
||||||
|
InjectFacturXXMPMock func(ctx context.Context, logger *slog.Logger, facturX FacturX, inputPath string) error
|
||||||
|
ReadPdfAConformanceMock func(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (engine *PdfEngineMock) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
func (engine *PdfEngineMock) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
||||||
@@ -78,6 +81,10 @@ func (engine *PdfEngineMock) Convert(ctx context.Context, logger *slog.Logger, f
|
|||||||
return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath)
|
return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (engine *PdfEngineMock) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
|
||||||
|
return engine.OptimizeImagesMock(ctx, logger, imageQuality, inputPath)
|
||||||
|
}
|
||||||
|
|
||||||
func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||||
return engine.ReadMetadataMock(ctx, logger, inputPath)
|
return engine.ReadMetadataMock(ctx, logger, inputPath)
|
||||||
}
|
}
|
||||||
@@ -94,8 +101,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 +129,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 +167,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 +205,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,17 +1,23 @@
|
|||||||
package gotenberg
|
package gotenberg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dlclark/regexp2"
|
"github.com/dlclark/regexp2"
|
||||||
|
"github.com/hashicorp/go-retryablehttp"
|
||||||
|
"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
|
||||||
@@ -21,6 +27,12 @@ import (
|
|||||||
// example [::ffff:127.0.0.1]).
|
// example [::ffff:127.0.0.1]).
|
||||||
var ErrNonPublicIP = errors.New("non-public IP")
|
var ErrNonPublicIP = errors.New("non-public IP")
|
||||||
|
|
||||||
|
// ErrFiltered happens when a value is rejected by an allow-list or a
|
||||||
|
// deny-list, or when it cannot be validated and [DecideOutbound] fails closed.
|
||||||
|
// Callers map it to a generic 403: the specific reason stays in the operator
|
||||||
|
// logs so a client cannot probe the lists.
|
||||||
|
var ErrFiltered = errors.New("value filtered")
|
||||||
|
|
||||||
// ErrPublicIP indicates that an outbound URL targets an IP address that is
|
// ErrPublicIP indicates that an outbound URL targets an IP address that is
|
||||||
// reachable on the public internet. It is returned when a caller opts
|
// reachable on the public internet. It is returned when a caller opts
|
||||||
// into denying public destinations via [WithDenyPublicIPs]; typical use
|
// into denying public destinations via [WithDenyPublicIPs]; typical use
|
||||||
@@ -73,6 +85,18 @@ var nonPublicIPv6Prefixes = []netip.Prefix{
|
|||||||
netip.MustParsePrefix("100::/64"),
|
netip.MustParsePrefix("100::/64"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nonPublicIPv4Prefixes lists IPv4 ranges that the [netip.Addr] helpers do
|
||||||
|
// not classify but that must not be considered public:
|
||||||
|
//
|
||||||
|
// - 100.64.0.0/10 Carrier-grade NAT (RFC 6598). Routable inside provider
|
||||||
|
// and cluster networks, and Alibaba Cloud serves instance metadata from
|
||||||
|
// 100.100.100.200.
|
||||||
|
// - 198.18.0.0/15 Benchmarking (RFC 2544). Never routed on the internet.
|
||||||
|
var nonPublicIPv4Prefixes = []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("100.64.0.0/10"),
|
||||||
|
netip.MustParsePrefix("198.18.0.0/15"),
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -83,7 +107,8 @@ var nonPublicIPv6Prefixes = []netip.Prefix{
|
|||||||
// (6to4, Teredo, NAT64) are rejected wholesale rather than recursed into,
|
// (6to4, Teredo, NAT64) are rejected wholesale rather than recursed into,
|
||||||
// because a host that routes them implicitly trusts the IPv4 mapping and
|
// because a host that routes them implicitly trusts the IPv4 mapping and
|
||||||
// the prefixes themselves are deprecated or translation-only. See
|
// the prefixes themselves are deprecated or translation-only. See
|
||||||
// [nonPublicIPv6Prefixes] for the full list and rationale.
|
// [nonPublicIPv6Prefixes] and [nonPublicIPv4Prefixes] for the full lists
|
||||||
|
// and rationale.
|
||||||
func IsPublicIP(addr netip.Addr) bool {
|
func IsPublicIP(addr netip.Addr) bool {
|
||||||
if !addr.IsValid() {
|
if !addr.IsValid() {
|
||||||
return false
|
return false
|
||||||
@@ -99,6 +124,13 @@ func IsPublicIP(addr netip.Addr) bool {
|
|||||||
addr.IsInterfaceLocalMulticast():
|
addr.IsInterfaceLocalMulticast():
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if addr.Is4() {
|
||||||
|
for _, p := range nonPublicIPv4Prefixes {
|
||||||
|
if p.Contains(addr) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if addr.Is6() {
|
if addr.Is6() {
|
||||||
for _, p := range nonPublicIPv6Prefixes {
|
for _, p := range nonPublicIPv6Prefixes {
|
||||||
if p.Contains(addr) {
|
if p.Contains(addr) {
|
||||||
@@ -183,6 +215,12 @@ type OutboundDecision struct {
|
|||||||
// is stored.
|
// is stored.
|
||||||
type outboundDecisionKey struct{}
|
type outboundDecisionKey struct{}
|
||||||
|
|
||||||
|
// outboundProxiedKey is the context key under which [outboundRoundTripper]
|
||||||
|
// records that the environment proxy will carry this request, so that the
|
||||||
|
// dialer knows the address it receives is the proxy's rather than the
|
||||||
|
// destination's.
|
||||||
|
type outboundProxiedKey struct{}
|
||||||
|
|
||||||
// decideConfig carries optional settings for [DecideOutbound] and
|
// decideConfig carries optional settings for [DecideOutbound] and
|
||||||
// [FilterOutboundURL]. See [DecideOption] for how callers configure it.
|
// [FilterOutboundURL]. See [DecideOption] for how callers configure it.
|
||||||
type decideConfig struct {
|
type decideConfig struct {
|
||||||
@@ -238,7 +276,9 @@ func httpLikeScheme(scheme string) bool {
|
|||||||
//
|
//
|
||||||
// The semantics:
|
// The semantics:
|
||||||
//
|
//
|
||||||
// 1. The URL is parsed and its scheme and host lowercased.
|
// 1. The URL is parsed, its scheme and host lowercased, and any userinfo
|
||||||
|
// dropped from the form the regexes see. The request still carries the
|
||||||
|
// credentials.
|
||||||
// 2. allowList and denyList apply against the normalized form with OR
|
// 2. allowList and denyList apply against the normalized form with OR
|
||||||
// semantics. The deny-list always applies.
|
// semantics. The deny-list always applies.
|
||||||
// 3. For http, https, ws, and wss, the host is resolved and every
|
// 3. For http, https, ws, and wss, the host is resolved and every
|
||||||
@@ -257,26 +297,44 @@ func DecideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r
|
|||||||
opt(&cfg)
|
opt(&cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each match is bounded by [PatternMatchTimeout] rather than by the
|
||||||
|
// remaining budget, so an already-spent deadline no longer surfaces from
|
||||||
|
// the match itself. Schemes that resolve a host still learn about it from
|
||||||
|
// resolveHost, but a non-matching file:// or data: URL returns before that
|
||||||
|
// point, so check it here to keep failing closed on every path.
|
||||||
|
if !time.Now().Before(deadline) {
|
||||||
|
return OutboundDecision{}, context.DeadlineExceeded
|
||||||
|
}
|
||||||
|
|
||||||
parsed, err := url.Parse(rawURL)
|
parsed, err := url.Parse(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return OutboundDecision{}, fmt.Errorf("parse URL %q: %w", rawURL, ErrFiltered)
|
return OutboundDecision{}, fmt.Errorf("parse URL %q: %w", rawURL, ErrFiltered)
|
||||||
}
|
}
|
||||||
parsed.Scheme = strings.ToLower(parsed.Scheme)
|
parsed.Scheme = strings.ToLower(parsed.Scheme)
|
||||||
parsed.Host = strings.ToLower(parsed.Host)
|
parsed.Host = strings.ToLower(parsed.Host)
|
||||||
normalized := parsed.String()
|
|
||||||
|
// Match on a credential-free form. [url.URL.String] re-emits userinfo
|
||||||
|
// between "scheme://" and the host, so keeping it would let any
|
||||||
|
// "^https?://<host>" pattern be shifted past its own anchor:
|
||||||
|
// http://a@127.0.0.1/ escapes a deny-list anchored on 127\. and
|
||||||
|
// http://trusted.example.com@10.0.0.1/ satisfies an allow-list anchored on
|
||||||
|
// trusted\.example\.com. The host checks below already read
|
||||||
|
// [url.URL.Hostname], which ignores userinfo, so only the regex layer was
|
||||||
|
// affected. Dropping the credentials here also keeps them out of the error
|
||||||
|
// strings below, which reach operator logs and any OTEL log exporter.
|
||||||
|
matchable := *parsed
|
||||||
|
matchable.User = nil
|
||||||
|
normalized := matchable.String()
|
||||||
|
|
||||||
allowMatched := false
|
allowMatched := false
|
||||||
if len(allowList) > 0 {
|
if len(allowList) > 0 {
|
||||||
for _, pattern := range allowList {
|
for _, pattern := range allowList {
|
||||||
clone := regexp2.MustCompile(pattern.String(), 0)
|
ok, err := pattern.MatchString(normalized)
|
||||||
clone.MatchTimeout = time.Until(deadline)
|
|
||||||
|
|
||||||
ok, err := clone.MatchString(normalized)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if time.Now().After(deadline) {
|
if time.Now().After(deadline) {
|
||||||
return OutboundDecision{}, context.DeadlineExceeded
|
return OutboundDecision{}, context.DeadlineExceeded
|
||||||
}
|
}
|
||||||
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
|
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", pattern.String(), normalized, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok {
|
if ok {
|
||||||
@@ -291,15 +349,12 @@ func DecideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, pattern := range denyList {
|
for _, pattern := range denyList {
|
||||||
clone := regexp2.MustCompile(pattern.String(), 0)
|
ok, err := pattern.MatchString(normalized)
|
||||||
clone.MatchTimeout = time.Until(deadline)
|
|
||||||
|
|
||||||
ok, err := clone.MatchString(normalized)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if time.Now().After(deadline) {
|
if time.Now().After(deadline) {
|
||||||
return OutboundDecision{}, context.DeadlineExceeded
|
return OutboundDecision{}, context.DeadlineExceeded
|
||||||
}
|
}
|
||||||
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
|
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", pattern.String(), normalized, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok {
|
if ok {
|
||||||
@@ -327,8 +382,18 @@ func DecideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r
|
|||||||
return OutboundDecision{}, fmt.Errorf("'%s' targets a non-public address: %w", normalized, ErrFiltered)
|
return OutboundDecision{}, fmt.Errorf("'%s' targets a non-public address: %w", normalized, ErrFiltered)
|
||||||
case errors.Is(err, ErrPublicIP):
|
case errors.Is(err, ErrPublicIP):
|
||||||
return OutboundDecision{}, fmt.Errorf("'%s' targets a public address: %w", normalized, ErrFiltered)
|
return OutboundDecision{}, fmt.Errorf("'%s' targets a public address: %w", normalized, ErrFiltered)
|
||||||
default:
|
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
|
||||||
|
// A cancellation or timeout is not a policy decision; surface it
|
||||||
|
// as-is so callers do not report it as a filtered request.
|
||||||
return OutboundDecision{}, fmt.Errorf("validate '%s' host: %w", normalized, err)
|
return OutboundDecision{}, fmt.Errorf("validate '%s' host: %w", normalized, err)
|
||||||
|
default:
|
||||||
|
// The host could not be resolved, so its address class cannot be
|
||||||
|
// verified. Fail closed and treat it as filtered, the same as a
|
||||||
|
// host that resolves to a blocked address, so clients get a
|
||||||
|
// generic 403 rather than a 500. This also denies alternate IP
|
||||||
|
// encodings such as http://2130706433/ that the resolver rejects
|
||||||
|
// as a hostname but Chromium would read as a private IP.
|
||||||
|
return OutboundDecision{}, fmt.Errorf("validate '%s' host: %v: %w", normalized, err, ErrFiltered)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,9 +401,8 @@ func DecideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FilterOutboundURL validates that rawURL is acceptable for an outbound
|
// FilterOutboundURL validates that rawURL is acceptable for an outbound
|
||||||
// request from Gotenberg. It is the URL-aware replacement for
|
// request from Gotenberg. Prefer it for any new code that filters a URL
|
||||||
// [FilterDeadline] and should be preferred for any new code that filters
|
// before issuing or instructing an outbound request.
|
||||||
// a URL before issuing or instructing an outbound request.
|
|
||||||
//
|
//
|
||||||
// The default behavior is permissive: the URL passes as long as it clears
|
// The default behavior is permissive: the URL passes as long as it clears
|
||||||
// the regex allow-list and deny-list. Callers that need IP-class checks
|
// the regex allow-list and deny-list. Callers that need IP-class checks
|
||||||
@@ -360,6 +424,10 @@ type outboundRoundTripper struct {
|
|||||||
allowList []*regexp2.Regexp
|
allowList []*regexp2.Regexp
|
||||||
denyList []*regexp2.Regexp
|
denyList []*regexp2.Regexp
|
||||||
opts []DecideOption
|
opts []DecideOption
|
||||||
|
|
||||||
|
// proxyFunc mirrors the transport's own proxy resolution. It is nil unless
|
||||||
|
// the environment proxy is enabled.
|
||||||
|
proxyFunc func(*url.URL) (*url.URL, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RoundTrip validates req.URL and delegates to the base transport.
|
// RoundTrip validates req.URL and delegates to the base transport.
|
||||||
@@ -375,6 +443,18 @@ func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(req.Context(), outboundDecisionKey{}, decision)
|
ctx := context.WithValue(req.Context(), outboundDecisionKey{}, decision)
|
||||||
|
|
||||||
|
// A request the proxy will not carry is dialed directly, so it still gets
|
||||||
|
// pinned. Without this, enabling the environment proxy would silently drop
|
||||||
|
// DNS-rebinding protection for every NO_PROXY host, and for all traffic
|
||||||
|
// when no proxy variable is set at all.
|
||||||
|
if rt.proxyFunc != nil {
|
||||||
|
proxyURL, proxyErr := rt.proxyFunc(req.URL)
|
||||||
|
if proxyErr == nil && proxyURL != nil {
|
||||||
|
ctx = context.WithValue(ctx, outboundProxiedKey{}, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return rt.base.RoundTrip(req.WithContext(ctx))
|
return rt.base.RoundTrip(req.WithContext(ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,9 +469,57 @@ 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. Dial pinning does not apply
|
||||||
|
// to a hop the proxy carries, since the proxy owns DNS and egress there; a hop
|
||||||
|
// the proxy declines, such as a NO_PROXY host, is dialed directly and stays
|
||||||
|
// pinned. The URL allow/deny and IP-class validation runs either way. 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 {
|
||||||
|
// A negative timeout means the caller's budget is already spent, which
|
||||||
|
// happens when it derives one from a deadline that has passed. [http.Client]
|
||||||
|
// treats any non-positive Timeout as no deadline at all, so passing it
|
||||||
|
// through would silently produce an unbounded client. Fail closed instead.
|
||||||
|
// Zero keeps meaning unbounded: callers that own the connection lifetime
|
||||||
|
// themselves pass it deliberately.
|
||||||
|
if timeout < 0 {
|
||||||
|
timeout = time.Nanosecond
|
||||||
|
}
|
||||||
|
|
||||||
base := http.DefaultTransport.(*http.Transport).Clone()
|
base := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
base.DialContext = secureDialContext
|
|
||||||
|
var proxyFunc func(*url.URL) (*url.URL, error)
|
||||||
|
|
||||||
|
if enableEnvironmentProxy {
|
||||||
|
// Route through the operator's proxy (standard env vars, credentials
|
||||||
|
// included). 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)
|
||||||
|
}
|
||||||
|
// Only a hop the proxy actually carries skips pinning: there the dial
|
||||||
|
// targets the proxy, not the destination, and the proxy owns DNS. A hop
|
||||||
|
// the proxy declines is dialed directly and stays pinned.
|
||||||
|
base.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
if proxied, _ := ctx.Value(outboundProxiedKey{}).(bool); proxied {
|
||||||
|
return outboundDialer.DialContext(ctx, network, addr)
|
||||||
|
}
|
||||||
|
return secureDialContext(ctx, network, addr)
|
||||||
|
}
|
||||||
|
} 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{
|
||||||
@@ -399,10 +527,79 @@ func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2
|
|||||||
allowList: allowList,
|
allowList: allowList,
|
||||||
denyList: denyList,
|
denyList: denyList,
|
||||||
opts: opts,
|
opts: opts,
|
||||||
|
proxyFunc: proxyFunc,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClampedBackoff is a [retryablehttp.Backoff] that honors max on every path.
|
||||||
|
//
|
||||||
|
// [retryablehttp.DefaultBackoff] returns a Retry-After header from the remote
|
||||||
|
// verbatim for 429 and 503, and returns it before applying its own max clamp.
|
||||||
|
// A hostile origin therefore decides how long Gotenberg waits, and the wait is
|
||||||
|
// not interruptible. Retry-After is still respected here, just never beyond
|
||||||
|
// the ceiling the caller set.
|
||||||
|
func ClampedBackoff(min, max time.Duration, attemptNum int, resp *http.Response) time.Duration {
|
||||||
|
wait := retryablehttp.DefaultBackoff(min, max, attemptNum, resp)
|
||||||
|
if wait > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
if wait < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return wait
|
||||||
|
}
|
||||||
|
|
||||||
|
// environmentProxyVariables are the variables golang.org/x/net/http/httpproxy
|
||||||
|
// reads, in the casing precedence it applies.
|
||||||
|
var environmentProxyVariables = []string{
|
||||||
|
"HTTP_PROXY", "http_proxy",
|
||||||
|
"HTTPS_PROXY", "https_proxy",
|
||||||
|
"ALL_PROXY", "all_proxy",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateEnvironmentProxyVariables checks that every proxy variable currently
|
||||||
|
// set can be parsed as a proxy URL.
|
||||||
|
//
|
||||||
|
// httpproxy discards a parse error and falls back to a direct connection, so an
|
||||||
|
// operator who mistypes a proxy URL would silently lose the egress path they
|
||||||
|
// meant to enforce. Modules exposing an environment proxy flag call this from
|
||||||
|
// their Validate so that startup fails loudly instead.
|
||||||
|
//
|
||||||
|
// Values are never included in the error: a proxy URL may carry credentials.
|
||||||
|
func ValidateEnvironmentProxyVariables() error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
for _, name := range environmentProxyVariables {
|
||||||
|
if os.Getenv(name) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isUsableProxyURL(os.Getenv(name)) {
|
||||||
|
err = errors.Join(err, fmt.Errorf("environment variable %s is not a usable proxy URL; unset it, or set it to a value like 'http://user:password@host:3128'", name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// isUsableProxyURL mirrors httpproxy's own parsing: a URL with a proxy scheme,
|
||||||
|
// or anything that becomes one once a scheme is prefixed.
|
||||||
|
func isUsableProxyURL(value string) bool {
|
||||||
|
proxyURL, err := url.Parse(value)
|
||||||
|
if err == nil {
|
||||||
|
switch proxyURL.Scheme {
|
||||||
|
case "http", "https", "socks5", "socks5h":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpproxy retries bare values such as "host:3128" with a scheme.
|
||||||
|
_, err = url.Parse("http://" + value)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
// secureDialContext consumes the [OutboundDecision] stashed in ctx by
|
// secureDialContext consumes the [OutboundDecision] stashed in ctx by
|
||||||
// [outboundRoundTripper]. When the decision is to bypass (allow-list
|
// [outboundRoundTripper]. When the decision is to bypass (allow-list
|
||||||
// match), it dials directly. When the decision contains pinned IPs, it
|
// match), it dials directly. When the decision contains pinned IPs, it
|
||||||
@@ -452,3 +649,110 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseWrite half-closes the underlying connection. Embedding [net.Conn] hides
|
||||||
|
// the method, so a CONNECT splice over this connection could never signal EOF
|
||||||
|
// to the upstream and both sides waited for the other until a timeout.
|
||||||
|
func (c *bufferedConn) CloseWrite() error {
|
||||||
|
cw, ok := c.Conn.(interface{ CloseWrite() error })
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("underlying %T does not support half-close", c.Conn)
|
||||||
|
}
|
||||||
|
return cw.CloseWrite()
|
||||||
|
}
|
||||||
|
|||||||
155
pkg/gotenberg/outbound_envproxy_test.go
Normal file
155
pkg/gotenberg/outbound_envproxy_test.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package gotenberg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateEnvironmentProxyVariables(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
env map[string]string
|
||||||
|
wantErr bool
|
||||||
|
// wantIn is a substring the error must name, so that an operator can
|
||||||
|
// find the offending variable.
|
||||||
|
wantIn string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nothing set",
|
||||||
|
env: map[string]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "well formed URL",
|
||||||
|
env: map[string]string{"HTTP_PROXY": "http://proxy.example.com:3128"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "credentials are accepted",
|
||||||
|
env: map[string]string{"HTTPS_PROXY": "http://user:password@proxy.example.com:3128"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare host and port is accepted, as httpproxy prefixes a scheme",
|
||||||
|
env: map[string]string{"HTTP_PROXY": "proxy.example.com:3128"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "socks5 is accepted",
|
||||||
|
env: map[string]string{"ALL_PROXY": "socks5://proxy.example.com:1080"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lowercase variables are checked too",
|
||||||
|
env: map[string]string{"http_proxy": "http://proxy.example.com:3128"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unparseable URL",
|
||||||
|
env: map[string]string{"HTTP_PROXY": "http://proxy.example.com:3128/%zz"},
|
||||||
|
wantErr: true,
|
||||||
|
wantIn: "HTTP_PROXY",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "the failing variable is named",
|
||||||
|
env: map[string]string{"HTTPS_PROXY": "://%zz"},
|
||||||
|
wantErr: true,
|
||||||
|
wantIn: "HTTPS_PROXY",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
for _, name := range environmentProxyVariables {
|
||||||
|
t.Setenv(name, "")
|
||||||
|
}
|
||||||
|
for name, value := range tc.env {
|
||||||
|
t.Setenv(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ValidateEnvironmentProxyVariables()
|
||||||
|
if tc.wantErr && err == nil {
|
||||||
|
t.Fatal("expected an error, got none")
|
||||||
|
}
|
||||||
|
if !tc.wantErr && err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if tc.wantIn != "" && !strings.Contains(err.Error(), tc.wantIn) {
|
||||||
|
t.Errorf("error %q does not name %q", err, tc.wantIn)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateEnvironmentProxyVariables_DoesNotLeakCredentials pins that a
|
||||||
|
// proxy URL, which may embed a password, never reaches the error text.
|
||||||
|
func TestValidateEnvironmentProxyVariables_DoesNotLeakCredentials(t *testing.T) {
|
||||||
|
for _, name := range environmentProxyVariables {
|
||||||
|
t.Setenv(name, "")
|
||||||
|
}
|
||||||
|
t.Setenv("HTTP_PROXY", "http://admin:hunter2@proxy.example.com:3128/%zz")
|
||||||
|
|
||||||
|
err := ValidateEnvironmentProxyVariables()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error, got none")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "hunter2") {
|
||||||
|
t.Errorf("error leaks the proxy password: %q", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "admin") {
|
||||||
|
t.Errorf("error leaks the proxy username: %q", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewOutboundHttpClient_EnvironmentProxyPinsDirectHops is the regression
|
||||||
|
// test for the dial-pinning gap: with the environment proxy enabled but no
|
||||||
|
// proxy applicable to the request, the dial must still go through the pinning
|
||||||
|
// dialer rather than a plain one.
|
||||||
|
//
|
||||||
|
// The request targets a hostname that only the stub resolver knows, so a plain
|
||||||
|
// dial would hand that unresolvable name to the OS and fail. Only a pinned dial,
|
||||||
|
// which substitutes the address resolved during validation, can connect.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1592.
|
||||||
|
func TestNewOutboundHttpClient_EnvironmentProxyPinsDirectHops(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
_, port, err := net.SplitHostPort(strings.TrimPrefix(srv.URL, "http://"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("split server address: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = "pinned-only.invalid"
|
||||||
|
|
||||||
|
// NO_PROXY covers the destination, so httpproxy declines it and the
|
||||||
|
// transport dials directly. That direct dial is the hop that used to lose
|
||||||
|
// pinning.
|
||||||
|
for _, name := range environmentProxyVariables {
|
||||||
|
t.Setenv(name, "")
|
||||||
|
}
|
||||||
|
t.Setenv("HTTP_PROXY", "http://proxy.invalid:3128")
|
||||||
|
t.Setenv("NO_PROXY", host)
|
||||||
|
|
||||||
|
withStubResolver(t, func(string) ([]netip.Addr, error) {
|
||||||
|
return []netip.Addr{netip.MustParseAddr("127.0.0.1")}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
client := NewOutboundHttpClient(0, nil, nil, true)
|
||||||
|
rt, ok := client.Transport.(*outboundRoundTripper)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("transport is %T, want *outboundRoundTripper", client.Transport)
|
||||||
|
}
|
||||||
|
if rt.proxyFunc == nil {
|
||||||
|
t.Fatal("proxyFunc is nil, want the environment proxy to be resolved per request")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Get("http://" + net.JoinHostPort(host, port))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET failed, so the direct hop was not pinned: %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ package gotenberg
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -38,6 +40,24 @@ func TestIsPublicIP(t *testing.T) {
|
|||||||
|
|
||||||
// Link-local.
|
// Link-local.
|
||||||
{"169.254.169.254", false},
|
{"169.254.169.254", false},
|
||||||
|
{"169.254.170.2", false},
|
||||||
|
|
||||||
|
// Carrier-grade NAT (RFC 6598). Alibaba Cloud serves instance
|
||||||
|
// metadata from 100.100.100.200.
|
||||||
|
{"100.64.0.0", false},
|
||||||
|
{"100.100.100.200", false},
|
||||||
|
{"100.127.255.255", false},
|
||||||
|
{"::ffff:100.100.100.200", false},
|
||||||
|
|
||||||
|
// Benchmarking (RFC 2544).
|
||||||
|
{"198.18.0.1", false},
|
||||||
|
{"198.19.255.255", false},
|
||||||
|
|
||||||
|
// Adjacent to the ranges above, and public.
|
||||||
|
{"100.63.255.255", true},
|
||||||
|
{"100.128.0.0", true},
|
||||||
|
{"198.17.255.255", true},
|
||||||
|
{"198.20.0.0", true},
|
||||||
{"fe80::1", false},
|
{"fe80::1", false},
|
||||||
|
|
||||||
// Unique-local.
|
// Unique-local.
|
||||||
@@ -311,6 +331,39 @@ func TestFilterOutboundURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDecideOutbound_UnresolvableHostFailsClosed(t *testing.T) {
|
||||||
|
withStubResolver(t, func(string) ([]netip.Addr, error) {
|
||||||
|
return nil, errors.New("no such host")
|
||||||
|
})
|
||||||
|
|
||||||
|
// An alternate IP encoding (decimal for 127.0.0.1) that the resolver
|
||||||
|
// rejects as a hostname must fail closed as filtered, not surface as a
|
||||||
|
// server error, so clients receive a generic 403.
|
||||||
|
_, err := DecideOutbound(context.Background(), "http://2130706433/", nil, nil, time.Now().Add(5*time.Second), WithDenyPrivateIPs(true))
|
||||||
|
if !errors.Is(err, ErrFiltered) {
|
||||||
|
t.Fatalf("expected ErrFiltered, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideOutbound_ResolverCancellationNotFiltered(t *testing.T) {
|
||||||
|
withStubResolver(t, func(string) ([]netip.Addr, error) {
|
||||||
|
return nil, context.Canceled
|
||||||
|
})
|
||||||
|
|
||||||
|
// A cancellation or timeout is not a policy decision and must not be
|
||||||
|
// reported as a filtered request.
|
||||||
|
_, err := DecideOutbound(context.Background(), "http://example.com/", nil, nil, time.Now().Add(5*time.Second), WithDenyPrivateIPs(true))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrFiltered) {
|
||||||
|
t.Fatalf("cancellation must not be filtered, got: %v", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("expected context.Canceled, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveAndCheckPublic_IPLiteralLoopback(t *testing.T) {
|
func TestResolveAndCheckPublic_IPLiteralLoopback(t *testing.T) {
|
||||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||||
t.Fatalf("unexpected DNS lookup for %q", host)
|
t.Fatalf("unexpected DNS lookup for %q", host)
|
||||||
@@ -487,3 +540,229 @@ func TestDecideOutbound_Permissive_AllowsPrivate(t *testing.T) {
|
|||||||
t.Fatalf("decision.Pinned = %v, want [10.0.0.5]", decision.Pinned)
|
t.Fatalf("decision.Pinned = %v, want [10.0.0.5]", decision.Pinned)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// privateIPsDenyList is the textual private-IP deny-list that shipped as the
|
||||||
|
// default for api-download-from-deny-list and webhook-deny-list in v8.31.0 and
|
||||||
|
// is still published as a migration recipe. Every alternative is anchored on
|
||||||
|
// "://", so userinfo used to slide the private address past the anchor.
|
||||||
|
const privateIPsDenyList = `^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)`
|
||||||
|
|
||||||
|
func TestDecideOutbound_UserinfoDoesNotEvadeDenyList(t *testing.T) {
|
||||||
|
for _, rawURL := range []string{
|
||||||
|
"http://127.0.0.1:9999/",
|
||||||
|
"http://a@127.0.0.1:9999/",
|
||||||
|
"http://@127.0.0.1:9999/",
|
||||||
|
"http://:@127.0.0.1:9999/",
|
||||||
|
"http://%61@127.0.0.1:9999/",
|
||||||
|
"http://user:pass@127.0.0.1:9999/",
|
||||||
|
"HTTP://A@127.0.0.1:9999/",
|
||||||
|
"http://a@169.254.169.254/latest/meta-data/",
|
||||||
|
// url.Parse takes the last "@" as the userinfo separator, so the host
|
||||||
|
// here is the second literal.
|
||||||
|
"http://a@127.0.0.1:9999@127.0.0.1:9999/",
|
||||||
|
} {
|
||||||
|
t.Run(rawURL, func(t *testing.T) {
|
||||||
|
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||||
|
t.Fatalf("unexpected DNS lookup for %q: the deny-list must reject before resolution", host)
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Deny-list only, with the permissive IP defaults the modules ship.
|
||||||
|
_, err := DecideOutbound(
|
||||||
|
context.Background(),
|
||||||
|
rawURL,
|
||||||
|
nil,
|
||||||
|
[]*regexp2.Regexp{regexp2.MustCompile(privateIPsDenyList, 0)},
|
||||||
|
time.Now().Add(5*time.Second),
|
||||||
|
)
|
||||||
|
if !errors.Is(err, ErrFiltered) {
|
||||||
|
t.Fatalf("userinfo must not evade the deny-list, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideOutbound_UserinfoDoesNotSatisfyAllowList(t *testing.T) {
|
||||||
|
// A host-terminated allow-list, the shape the documentation recommends.
|
||||||
|
allowList := []*regexp2.Regexp{regexp2.MustCompile(`^https://trusted\.example\.com(:[0-9]+)?(/|$)`, 0)}
|
||||||
|
|
||||||
|
for _, rawURL := range []string{
|
||||||
|
"https://trusted.example.com@169.254.169.254/latest/meta-data/",
|
||||||
|
"https://trusted.example.com@10.0.0.5/",
|
||||||
|
"https://trusted.example.com:443@10.0.0.5/",
|
||||||
|
} {
|
||||||
|
t.Run(rawURL, func(t *testing.T) {
|
||||||
|
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||||
|
return mustAddrs(t, "10.0.0.5"), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
decision, err := DecideOutbound(
|
||||||
|
context.Background(),
|
||||||
|
rawURL,
|
||||||
|
allowList, nil,
|
||||||
|
time.Now().Add(5*time.Second),
|
||||||
|
WithDenyPrivateIPs(true),
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("userinfo must not satisfy the allow-list, got decision %+v", decision)
|
||||||
|
}
|
||||||
|
if decision.Bypass {
|
||||||
|
t.Fatal("userinfo must never produce a bypass")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideOutbound_UserinfoKeptOutOfErrorMessages(t *testing.T) {
|
||||||
|
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||||
|
t.Fatalf("unexpected DNS lookup for %q", host)
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := DecideOutbound(
|
||||||
|
context.Background(),
|
||||||
|
"http://alice:hunter2@127.0.0.1:9999/",
|
||||||
|
nil,
|
||||||
|
[]*regexp2.Regexp{regexp2.MustCompile(privateIPsDenyList, 0)},
|
||||||
|
time.Now().Add(5*time.Second),
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected the URL to be filtered")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "hunter2") || strings.Contains(err.Error(), "alice") {
|
||||||
|
t.Fatalf("error message must not leak URL credentials: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideOutbound_LegitimateCredentialsStillReachTheHost(t *testing.T) {
|
||||||
|
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||||
|
if host != "example.com" {
|
||||||
|
t.Fatalf("host = %q, want example.com: userinfo must not reach resolution", host)
|
||||||
|
}
|
||||||
|
return mustAddrs(t, "93.184.216.34"), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Stripping userinfo is a matching concern only. A credentialed URL that
|
||||||
|
// breaks no rule must still be allowed through.
|
||||||
|
decision, err := DecideOutbound(
|
||||||
|
context.Background(),
|
||||||
|
"https://alice:hunter2@example.com/report.pdf",
|
||||||
|
[]*regexp2.Regexp{regexp2.MustCompile(`^https://example\.com(:[0-9]+)?(/|$)`, 0)},
|
||||||
|
nil,
|
||||||
|
time.Now().Add(5*time.Second),
|
||||||
|
WithDenyPrivateIPs(true),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("credentialed URL matching the allow-list must pass, got: %v", err)
|
||||||
|
}
|
||||||
|
if !decision.Bypass {
|
||||||
|
t.Fatalf("decision.Bypass = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClampedBackoff(t *testing.T) {
|
||||||
|
const (
|
||||||
|
min = 1 * time.Second
|
||||||
|
max = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
retryAfter := func(status int, seconds string) *http.Response {
|
||||||
|
return &http.Response{StatusCode: status, Header: http.Header{"Retry-After": []string{seconds}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
resp *http.Response
|
||||||
|
want time.Duration
|
||||||
|
}{
|
||||||
|
{"429 with an hour is clamped", retryAfter(http.StatusTooManyRequests, "3600"), max},
|
||||||
|
{"429 with a day is clamped", retryAfter(http.StatusTooManyRequests, "86400"), max},
|
||||||
|
{"503 with an hour is clamped", retryAfter(http.StatusServiceUnavailable, "3600"), max},
|
||||||
|
{"429 under the ceiling is honored", retryAfter(http.StatusTooManyRequests, "5"), 5 * time.Second},
|
||||||
|
{"no response falls back to exponential", nil, min},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
got := ClampedBackoff(min, max, 0, tc.resp)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("ClampedBackoff = %s, want %s", got, tc.want)
|
||||||
|
}
|
||||||
|
if got > max {
|
||||||
|
t.Fatalf("ClampedBackoff = %s, which exceeds max %s", got, max)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A negative max means the caller's budget is spent. The backoff must not
|
||||||
|
// return a negative duration, which would make the retry loop spin.
|
||||||
|
func TestClampedBackoff_NegativeMaxIsNotNegative(t *testing.T) {
|
||||||
|
got := ClampedBackoff(1*time.Second, -5*time.Second, 0, nil)
|
||||||
|
if got < 0 {
|
||||||
|
t.Fatalf("ClampedBackoff = %s, want a non-negative duration", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewOutboundHttpClient_NonPositiveTimeout(t *testing.T) {
|
||||||
|
// Zero stays unbounded: the LibreOffice proxy owns its own lifetime and
|
||||||
|
// passes it deliberately.
|
||||||
|
if got := NewOutboundHttpClient(0, nil, nil, false).Timeout; got != 0 {
|
||||||
|
t.Fatalf("timeout for 0 = %s, want 0", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Negative means an expired budget. http.Client reads any non-positive
|
||||||
|
// Timeout as no deadline at all, so it must not be passed through.
|
||||||
|
if got := NewOutboundHttpClient(-5*time.Second, nil, nil, false).Timeout; got <= 0 {
|
||||||
|
t.Fatalf("timeout for a negative budget = %s, want a positive value so the client fails closed", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideOutboundExpiredDeadline(t *testing.T) {
|
||||||
|
// Patterns are matched under the fixed PatternMatchTimeout rather than
|
||||||
|
// under the caller's remaining budget, so an expired deadline no longer
|
||||||
|
// surfaces from the match itself. Every scheme must still fail closed,
|
||||||
|
// including the ones that return before a host is resolved.
|
||||||
|
expired := time.Now().Add(-time.Second)
|
||||||
|
|
||||||
|
for _, rawURL := range []string{
|
||||||
|
"https://example.com/",
|
||||||
|
"file:///tmp/foo.html",
|
||||||
|
"data:text/html,hello",
|
||||||
|
} {
|
||||||
|
_, err := DecideOutbound(context.Background(), rawURL, nil, nil, expired)
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("DecideOutbound(%q) with an expired deadline = %v, want context.DeadlineExceeded", rawURL, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideOutboundBoundsCatastrophicPatterns(t *testing.T) {
|
||||||
|
// A deny-list pattern that backtracks catastrophically, matched against a
|
||||||
|
// client-controlled URL. Before PatternMatchTimeout the ceiling was the
|
||||||
|
// caller's whole budget, so a 30s API_TIMEOUT bought a 30s CPU burn.
|
||||||
|
// The trailing "!" makes the match fail only after the nested quantifier
|
||||||
|
// has explored every way to split the run of "a"s.
|
||||||
|
pattern := regexp2.MustCompile(`^https://example\.com/(a+)+$`, 0)
|
||||||
|
pattern.MatchTimeout = PatternMatchTimeout
|
||||||
|
|
||||||
|
rawURL := "https://example.com/" + strings.Repeat("a", 40) + "!"
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, err := DecideOutbound(
|
||||||
|
context.Background(),
|
||||||
|
rawURL,
|
||||||
|
nil,
|
||||||
|
[]*regexp2.Regexp{pattern},
|
||||||
|
time.Now().Add(30*time.Second),
|
||||||
|
)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error from a catastrophic deny-list pattern")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generous headroom over the 250ms ceiling, still far below the 30s
|
||||||
|
// deadline the match would otherwise have been allowed to consume.
|
||||||
|
if elapsed > 5*time.Second {
|
||||||
|
t.Fatalf("match took %s, want it aborted near PatternMatchTimeout (%s)", elapsed, PatternMatchTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -174,6 +281,12 @@ type PdfEngine interface {
|
|||||||
// PdfFormats. If no format, it does nothing.
|
// PdfFormats. If no format, it does nothing.
|
||||||
Convert(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
|
Convert(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
|
||||||
|
|
||||||
|
// OptimizeImages re-encodes the raster images of a PDF in place to shrink
|
||||||
|
// the file, leaving text, vectors, fonts and structure untouched.
|
||||||
|
// imageQuality is the JPEG quality (1 to 100) applied to each re-encoded
|
||||||
|
// image.
|
||||||
|
OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error
|
||||||
|
|
||||||
// ReadMetadata extracts the metadata of a given PDF file.
|
// ReadMetadata extracts the metadata of a given PDF file.
|
||||||
ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
|
ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
|
||||||
|
|
||||||
@@ -190,11 +303,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 +329,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].
|
||||||
|
|||||||
@@ -201,17 +201,13 @@ func TestNewServerRecordMetrics(t *testing.T) {
|
|||||||
server.RecordMetrics(t.Context(), semconv.ServerMetricData{
|
server.RecordMetrics(t.Context(), semconv.ServerMetricData{
|
||||||
ServerName: "stuff",
|
ServerName: "stuff",
|
||||||
ResponseSize: 200,
|
ResponseSize: 200,
|
||||||
MetricAttributes: semconv.MetricAttributes{
|
Req: req,
|
||||||
Req: req,
|
StatusCode: 301,
|
||||||
StatusCode: 301,
|
AdditionalAttributes: []attribute.KeyValue{
|
||||||
AdditionalAttributes: []attribute.KeyValue{
|
attribute.String("key", "value"),
|
||||||
attribute.String("key", "value"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MetricData: semconv.MetricData{
|
|
||||||
RequestSize: 100,
|
|
||||||
ElapsedTime: 300,
|
|
||||||
},
|
},
|
||||||
|
RequestSize: 100,
|
||||||
|
ElapsedTime: 300,
|
||||||
})
|
})
|
||||||
|
|
||||||
rm := metricdata.ResourceMetrics{}
|
rm := metricdata.ResourceMetrics{}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -55,8 +59,9 @@ type ProcessSupervisor interface {
|
|||||||
// Healthy checks and returns the health status of the managed [Process].
|
// Healthy checks and returns the health status of the managed [Process].
|
||||||
//
|
//
|
||||||
// A non-started process is considered healthy (startup is deferred until
|
// A non-started process is considered healthy (startup is deferred until
|
||||||
// the first request). Returns false if the process is currently restarting
|
// the first request), as is one going through a planned restart, since it
|
||||||
// or is reported unhealthy by the underlying [Process].
|
// keeps serving traffic. Returns false during an unplanned restart or when
|
||||||
|
// the underlying [Process] reports unhealthy.
|
||||||
Healthy() bool
|
Healthy() bool
|
||||||
|
|
||||||
// Run executes a provided task while managing the state of the [Process].
|
// Run executes a provided task while managing the state of the [Process].
|
||||||
@@ -76,6 +81,10 @@ type ProcessSupervisor interface {
|
|||||||
|
|
||||||
// ActiveTasksCount returns the current number of active tasks.
|
// ActiveTasksCount returns the current number of active tasks.
|
||||||
ActiveTasksCount() int64
|
ActiveTasksCount() int64
|
||||||
|
|
||||||
|
// ConversionsSinceRestart returns the number of tasks handled since the
|
||||||
|
// last process (re)start.
|
||||||
|
ConversionsSinceRestart() int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// healthCheckCacheTTL caches successful health probe results so kubelet-
|
// healthCheckCacheTTL caches successful health probe results so kubelet-
|
||||||
@@ -95,8 +104,31 @@ const healthCheckCacheTTL = 2 * time.Second
|
|||||||
// this. See https://github.com/gotenberg/gotenberg/issues/1561.
|
// this. See https://github.com/gotenberg/gotenberg/issues/1561.
|
||||||
const healthFailureThreshold = 2
|
const healthFailureThreshold = 2
|
||||||
|
|
||||||
|
// Restart reasons, also reported as the gotenberg.process.start.reason span
|
||||||
|
// attribute by [processSupervisor.tracedLaunch]. Only
|
||||||
|
// [restartReasonMaxRequests] is a planned restart: it fires on a healthy
|
||||||
|
// process that reached its conversion limit, so the node keeps serving
|
||||||
|
// traffic throughout. The others signal a process that cannot serve.
|
||||||
|
const (
|
||||||
|
restartReasonFirstStart = "first_start"
|
||||||
|
restartReasonUnhealthy = "unhealthy"
|
||||||
|
restartReasonMaxRequests = "max_requests"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultEagerRestartTimeout bounds the restart triggered after the maximum
|
||||||
|
// request limit. That restart runs on a background context, unlike the one from
|
||||||
|
// ensureHealthy which inherits the request deadline, so without a deadline of
|
||||||
|
// its own the drain loop in [processSupervisor.doRestartLocked] would wait
|
||||||
|
// forever on a task that never completes. That would pin isRestarting and,
|
||||||
|
// with it, the health reported by [processSupervisor.Healthy]. Sized well above
|
||||||
|
// --api-timeout (30s by default) plus the engine start timeouts (20s by
|
||||||
|
// default) so it never fires while tasks are merely slow. The eager restart is
|
||||||
|
// opportunistic: on expiry it aborts, and the next task retries it.
|
||||||
|
const defaultEagerRestartTimeout = 2 * time.Minute
|
||||||
|
|
||||||
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
|
||||||
@@ -109,11 +141,16 @@ type processSupervisor struct {
|
|||||||
// transient failure (such as a cold-start timeout) must not poison the
|
// transient failure (such as a cold-start timeout) must not poison the
|
||||||
// supervisor for the rest of the container's lifetime. See
|
// supervisor for the rest of the container's lifetime. See
|
||||||
// https://github.com/gotenberg/gotenberg/issues/1538.
|
// https://github.com/gotenberg/gotenberg/issues/1538.
|
||||||
firstStartMu sync.Mutex
|
firstStartMu sync.Mutex
|
||||||
reqCounter atomic.Int64
|
reqCounter atomic.Int64
|
||||||
reqQueueSize atomic.Int64
|
reqQueueSize atomic.Int64
|
||||||
restartsCounter atomic.Int64
|
restartsCounter atomic.Int64
|
||||||
isRestarting atomic.Bool
|
isRestarting atomic.Bool
|
||||||
|
// restartPlanned records whether the in-flight restart is a planned one
|
||||||
|
// (see [restartReasonMaxRequests]). Written before isRestarting and never
|
||||||
|
// cleared, so a reader that observed isRestarting always sees the matching
|
||||||
|
// kind. See [processSupervisor.Healthy].
|
||||||
|
restartPlanned atomic.Bool
|
||||||
activeTasks atomic.Int64
|
activeTasks atomic.Int64
|
||||||
restartMutex sync.Mutex
|
restartMutex sync.Mutex
|
||||||
idleShutdownTimeout time.Duration
|
idleShutdownTimeout time.Duration
|
||||||
@@ -126,22 +163,33 @@ type processSupervisor struct {
|
|||||||
consecutiveHealthFailures atomic.Int64 // reset to 0 on every successful probe
|
consecutiveHealthFailures atomic.Int64 // reset to 0 on every successful probe
|
||||||
idleMu sync.Mutex // protects idleStopChan
|
idleMu sync.Mutex // protects idleStopChan
|
||||||
idleStopChan chan struct{} // signal to stop the idle ticker goroutine
|
idleStopChan chan struct{} // signal to stop the idle ticker goroutine
|
||||||
|
// eagerRestartTimeout bounds the restart from maybeRestartAfterTask.
|
||||||
|
// Defaults to [defaultEagerRestartTimeout]; only tests shorten it.
|
||||||
|
eagerRestartTimeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
maxQueueSize: maxQueueSize,
|
maxQueueSize: maxQueueSize,
|
||||||
maxConcurrency: maxConcurrency,
|
maxConcurrency: maxConcurrency,
|
||||||
idleShutdownTimeout: idleShutdownTimeout,
|
idleShutdownTimeout: idleShutdownTimeout,
|
||||||
|
eagerRestartTimeout: defaultEagerRestartTimeout,
|
||||||
}
|
}
|
||||||
b.reqCounter.Store(0)
|
b.reqCounter.Store(0)
|
||||||
b.reqQueueSize.Store(0)
|
b.reqQueueSize.Store(0)
|
||||||
@@ -196,12 +244,18 @@ func (s *processSupervisor) restart() error {
|
|||||||
s.logger.WarnContext(context.Background(), fmt.Sprintf("stop process before restart: %s", err))
|
s.logger.WarnContext(context.Background(), fmt.Sprintf("stop process before restart: %s", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset the counter on the attempt, not on its outcome. Leaving it at the
|
||||||
|
// limit after a failed launch re-triggers maybeRestartAfterTask on every
|
||||||
|
// subsequent task, producing back-to-back restarts. Recovering a process
|
||||||
|
// that will not start is ensureHealthy's job: it restarts synchronously
|
||||||
|
// before running a task, and reports the failure to the caller.
|
||||||
|
s.reqCounter.Store(0)
|
||||||
|
|
||||||
err = s.Launch()
|
err = s.Launch()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("restart process: %w", err)
|
return fmt.Errorf("restart process: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.reqCounter.Store(0)
|
|
||||||
s.restartsCounter.Add(1)
|
s.restartsCounter.Add(1)
|
||||||
s.logger.DebugContext(context.Background(), "process successfully restarted")
|
s.logger.DebugContext(context.Background(), "process successfully restarted")
|
||||||
|
|
||||||
@@ -218,9 +272,17 @@ 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 planned restart is routine maintenance: the process reached the
|
||||||
// honest information so they can avoid routing traffic to this node.
|
// limit set by --chromium-restart-after (env CHROMIUM_RESTART_AFTER) or
|
||||||
return false
|
// --libreoffice-restart-after (env LIBREOFFICE_RESTART_AFTER) while
|
||||||
|
// healthy. Tasks arriving during it are requeued by acquireSlot, not
|
||||||
|
// rejected, so the node still serves traffic and must report healthy. A
|
||||||
|
// probe sent between two conversions used to fail here.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1648.
|
||||||
|
//
|
||||||
|
// An unplanned restart keeps reporting unhealthy, which gives load
|
||||||
|
// balancers honest information so they can avoid routing traffic here.
|
||||||
|
return s.restartPlanned.Load()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache hit: a recent probe succeeded. Skip the CDP roundtrip so probe
|
// Cache hit: a recent probe succeeded. Skip the CDP roundtrip so probe
|
||||||
@@ -267,6 +329,20 @@ func (s *processSupervisor) recentlyHealthy() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *processSupervisor) Run(ctx context.Context, logger *slog.Logger, task func() error) error {
|
func (s *processSupervisor) Run(ctx context.Context, logger *slog.Logger, task func() error) error {
|
||||||
|
// Time spent before the task body runs: queueing, slot acquisition, lazy
|
||||||
|
// launch, and health checks. Ended once, when the task is about to execute.
|
||||||
|
_, queueSpan := Tracer().Start(ctx, s.engine+".queue.wait",
|
||||||
|
trace.WithSpanKind(trace.SpanKindInternal),
|
||||||
|
)
|
||||||
|
queueWaitDone := false
|
||||||
|
endQueueWait := func() {
|
||||||
|
if !queueWaitDone {
|
||||||
|
queueWaitDone = true
|
||||||
|
queueSpan.End()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer endQueueWait()
|
||||||
|
|
||||||
// Atomically check and increment the queue size to avoid the TOCTOU race
|
// Atomically check and increment the queue size to avoid the TOCTOU race
|
||||||
// originally reported in https://github.com/gotenberg/gotenberg/issues/951.
|
// originally reported in https://github.com/gotenberg/gotenberg/issues/951.
|
||||||
for {
|
for {
|
||||||
@@ -315,6 +391,7 @@ func (s *processSupervisor) Run(ctx context.Context, logger *slog.Logger, task f
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
endQueueWait()
|
||||||
err := s.runWithDeadline(ctx, task)
|
err := s.runWithDeadline(ctx, task)
|
||||||
|
|
||||||
if s.maybeRestartAfterTask(logger) {
|
if s.maybeRestartAfterTask(logger) {
|
||||||
@@ -453,8 +530,8 @@ func (s *processSupervisor) ensureStarted(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err := s.runWithDeadline(ctx, func() error {
|
err := s.tracedLaunch(ctx, restartReasonFirstStart, func() error {
|
||||||
return s.Launch()
|
return s.runWithDeadline(ctx, s.Launch)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("process first start: %w", err)
|
return fmt.Errorf("process first start: %w", err)
|
||||||
@@ -463,6 +540,27 @@ func (s *processSupervisor) ensureStarted(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tracedLaunch wraps a process (re)start in an <engine>.process.start span,
|
||||||
|
// tagged with the reason that triggered it. The eager restart after the maximum
|
||||||
|
// request limit runs on a background context, so its span is a detached root.
|
||||||
|
func (s *processSupervisor) tracedLaunch(ctx context.Context, reason string, launch func() error) error {
|
||||||
|
_, span := Tracer().Start(ctx, s.engine+".process.start",
|
||||||
|
trace.WithSpanKind(trace.SpanKindInternal),
|
||||||
|
trace.WithAttributes(attribute.String("gotenberg.process.start.reason", reason)),
|
||||||
|
)
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
err := launch()
|
||||||
|
if err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
span.SetStatus(codes.Ok, "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ensureHealthy checks the underlying process health and triggers a
|
// ensureHealthy checks the underlying process health and triggers a
|
||||||
// synchronous restart if the process is unhealthy. Skips the check if a
|
// synchronous restart if the process is unhealthy. Skips the check if a
|
||||||
// restart is already in progress.
|
// restart is already in progress.
|
||||||
@@ -473,7 +571,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, restartReasonUnhealthy); err != nil {
|
||||||
return fmt.Errorf("process restart before task: %w", err)
|
return fmt.Errorf("process restart before task: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,9 +579,10 @@ func (s *processSupervisor) ensureHealthy(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// maybeRestartAfterTask checks if the maximum request limit has been reached
|
// maybeRestartAfterTask checks if the maximum request limit has been reached
|
||||||
// and, if so, triggers an asynchronous restart. If a restart is initiated, it
|
// and, if so, triggers an asynchronous restart bounded by
|
||||||
// takes ownership of the caller's semaphore slot (the caller must not release
|
// [defaultEagerRestartTimeout]. If a restart is initiated, it takes ownership
|
||||||
// it). Returns true if ownership was taken.
|
// of the caller's semaphore slot (the caller must not release it). Returns true
|
||||||
|
// if ownership was taken.
|
||||||
func (s *processSupervisor) maybeRestartAfterTask(logger *slog.Logger) bool {
|
func (s *processSupervisor) maybeRestartAfterTask(logger *slog.Logger) bool {
|
||||||
if s.maxReqLimit <= 0 || s.reqCounter.Load() < s.maxReqLimit {
|
if s.maxReqLimit <= 0 || s.reqCounter.Load() < s.maxReqLimit {
|
||||||
return false
|
return false
|
||||||
@@ -496,7 +595,10 @@ 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())
|
ctx, cancel := context.WithTimeout(context.Background(), s.eagerRestartTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
restartErr := s.doRestartLocked(ctx, restartReasonMaxRequests)
|
||||||
s.restartMutex.Unlock()
|
s.restartMutex.Unlock()
|
||||||
if restartErr != nil {
|
if restartErr != nil {
|
||||||
s.logger.ErrorContext(context.Background(), fmt.Sprintf("process restart after task: %v", restartErr))
|
s.logger.ErrorContext(context.Background(), fmt.Sprintf("process restart after task: %v", restartErr))
|
||||||
@@ -510,15 +612,19 @@ 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 {
|
||||||
|
// Publish the kind before raising the flag. [processSupervisor.Healthy]
|
||||||
|
// reads restartPlanned only after it observes isRestarting, so this
|
||||||
|
// ordering keeps it from pairing a new restart with a stale kind.
|
||||||
|
s.restartPlanned.Store(reason == restartReasonMaxRequests)
|
||||||
s.isRestarting.Store(true)
|
s.isRestarting.Store(true)
|
||||||
defer s.isRestarting.Store(false)
|
defer s.isRestarting.Store(false)
|
||||||
|
|
||||||
@@ -538,8 +644,8 @@ func (s *processSupervisor) doRestartLocked(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err := s.runWithDeadline(ctx, func() error {
|
err := s.tracedLaunch(ctx, reason, func() error {
|
||||||
return s.restart()
|
return s.runWithDeadline(ctx, s.restart)
|
||||||
})
|
})
|
||||||
|
|
||||||
for range acquired {
|
for range acquired {
|
||||||
@@ -577,6 +683,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()
|
||||||
|
|
||||||
@@ -160,13 +160,49 @@ func TestProcessSupervisor_restart(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProcessSupervisor_restart_ResetsCounterOnFailedLaunch verifies that a
|
||||||
|
// restart whose launch fails still clears the request counter. Leaving it at
|
||||||
|
// the limit makes maybeRestartAfterTask re-fire on every subsequent task.
|
||||||
|
func TestProcessSupervisor_restart_ResetsCounterOnFailedLaunch(t *testing.T) {
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
|
const maxReqLimit = 5
|
||||||
|
|
||||||
|
process := &ProcessMock{
|
||||||
|
StartMock: func(_ *slog.Logger) error { return errors.New("start error") },
|
||||||
|
StopMock: func(_ *slog.Logger) error { return nil },
|
||||||
|
HealthyMock: func(_ *slog.Logger) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
ps := NewProcessSupervisor(logger, "test", process, maxReqLimit, 0, 1, 0).(*processSupervisor)
|
||||||
|
ps.reqCounter.Store(maxReqLimit)
|
||||||
|
|
||||||
|
err := ps.restart()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error but got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := ps.reqCounter.Load(); got != 0 {
|
||||||
|
t.Fatalf("expected the request counter to be reset but got %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := ps.restartsCounter.Load(); got != 0 {
|
||||||
|
t.Fatalf("expected the restarts counter to stay at 0 but got %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ps.maybeRestartAfterTask(logger) {
|
||||||
|
t.Fatal("expected no further eager restart to be triggered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProcessSupervisor_Healthy(t *testing.T) {
|
func TestProcessSupervisor_Healthy(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
scenario string
|
scenario string
|
||||||
initiallyStarted bool
|
initiallyStarted bool
|
||||||
initiallyRestarting bool
|
initiallyRestarting bool
|
||||||
processHealthy bool
|
initiallyRestartPlanned bool
|
||||||
expectHealthy bool
|
processHealthy bool
|
||||||
|
expectHealthy bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
scenario: "non-started process is healthy",
|
scenario: "non-started process is healthy",
|
||||||
@@ -179,6 +215,13 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
|
|||||||
initiallyRestarting: true,
|
initiallyRestarting: true,
|
||||||
expectHealthy: false,
|
expectHealthy: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
scenario: "process going through a planned restart is healthy",
|
||||||
|
initiallyStarted: true,
|
||||||
|
initiallyRestarting: true,
|
||||||
|
initiallyRestartPlanned: true,
|
||||||
|
expectHealthy: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
scenario: "process reports as healthy",
|
scenario: "process reports as healthy",
|
||||||
initiallyStarted: true,
|
initiallyStarted: true,
|
||||||
@@ -201,13 +244,16 @@ 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)
|
||||||
}
|
}
|
||||||
if tc.initiallyRestarting {
|
if tc.initiallyRestarting {
|
||||||
ps.isRestarting.Store(true)
|
ps.isRestarting.Store(true)
|
||||||
}
|
}
|
||||||
|
if tc.initiallyRestartPlanned {
|
||||||
|
ps.restartPlanned.Store(true)
|
||||||
|
}
|
||||||
|
|
||||||
healthy := ps.Healthy()
|
healthy := ps.Healthy()
|
||||||
|
|
||||||
@@ -229,7 +275,7 @@ func TestProcessSupervisor_Healthy_ConsecutiveFailures(t *testing.T) {
|
|||||||
HealthyMock: func(_ *slog.Logger) bool { return processHealthy.Load() },
|
HealthyMock: func(_ *slog.Logger) bool { return processHealthy.Load() },
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
|
||||||
ps.firstStart.Store(true)
|
ps.firstStart.Store(true)
|
||||||
|
|
||||||
processHealthy.Store(false)
|
processHealthy.Store(false)
|
||||||
@@ -257,6 +303,204 @@ func TestProcessSupervisor_Healthy_ConsecutiveFailures(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProcessSupervisor_Healthy_PlannedRestart reproduces
|
||||||
|
// https://github.com/gotenberg/gotenberg/issues/1648. It drives the real
|
||||||
|
// Run() path until the maximum request limit triggers the eager restart, then
|
||||||
|
// asserts the supervisor reports healthy while that restart is in flight.
|
||||||
|
// Tasks arriving during it are requeued by acquireSlot, not rejected, so the
|
||||||
|
// node still serves traffic.
|
||||||
|
func TestProcessSupervisor_Healthy_PlannedRestart(t *testing.T) {
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
|
const maxReqLimit = 10
|
||||||
|
|
||||||
|
restarting := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
|
||||||
|
var (
|
||||||
|
starts atomic.Int64
|
||||||
|
signalOne sync.Once
|
||||||
|
)
|
||||||
|
process := &ProcessMock{
|
||||||
|
StartMock: func(_ *slog.Logger) error {
|
||||||
|
// Hold the restart open so the assertions below run inside the
|
||||||
|
// window that used to report unhealthy.
|
||||||
|
if starts.Add(1) > 1 {
|
||||||
|
signalOne.Do(func() { close(restarting) })
|
||||||
|
<-release
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
StopMock: func(_ *slog.Logger) error { return nil },
|
||||||
|
HealthyMock: func(_ *slog.Logger) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
ps := NewProcessSupervisor(logger, "test", process, maxReqLimit, 0, 1, 0).(*processSupervisor)
|
||||||
|
|
||||||
|
for i := range maxReqLimit {
|
||||||
|
err := ps.Run(context.Background(), logger, func() error { return nil })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("task %d: expected no error but got: %v", i+1, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-restarting:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatalf("expected an eager restart after %d tasks", maxReqLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ps.isRestarting.Load() {
|
||||||
|
t.Fatal("expected the supervisor to be restarting")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ps.restartPlanned.Load() {
|
||||||
|
t.Fatal("expected the restart to be flagged as planned")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ps.Healthy() {
|
||||||
|
t.Fatal("expected a planned restart to report healthy")
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessSupervisor_Healthy_UnplannedRestart verifies the counterpart of
|
||||||
|
// [TestProcessSupervisor_Healthy_PlannedRestart]: a restart triggered by an
|
||||||
|
// unhealthy process keeps reporting unhealthy, so load balancers get honest
|
||||||
|
// information.
|
||||||
|
func TestProcessSupervisor_Healthy_UnplannedRestart(t *testing.T) {
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
|
restarting := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
|
||||||
|
var signalOne sync.Once
|
||||||
|
process := &ProcessMock{
|
||||||
|
StartMock: func(_ *slog.Logger) error {
|
||||||
|
signalOne.Do(func() { close(restarting) })
|
||||||
|
<-release
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
StopMock: func(_ *slog.Logger) error { return nil },
|
||||||
|
HealthyMock: func(_ *slog.Logger) bool { return false },
|
||||||
|
}
|
||||||
|
|
||||||
|
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, 0).(*processSupervisor)
|
||||||
|
ps.firstStart.Store(true)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_ = ps.ensureHealthy(context.Background())
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-restarting:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("expected an unhealthy restart to be triggered")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ps.isRestarting.Load() {
|
||||||
|
t.Fatal("expected the supervisor to be restarting")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ps.restartPlanned.Load() {
|
||||||
|
t.Fatal("expected the restart not to be flagged as planned")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ps.Healthy() {
|
||||||
|
t.Fatal("expected an unplanned restart to report unhealthy")
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessSupervisor_doRestartLocked_DrainDeadline verifies that a drain
|
||||||
|
// unable to acquire every slot gives up on the context deadline and clears
|
||||||
|
// isRestarting. Without a deadline on the eager restart, a task that never
|
||||||
|
// completes would pin the flag and, since a planned restart reports healthy,
|
||||||
|
// leave the supervisor claiming health forever.
|
||||||
|
func TestProcessSupervisor_doRestartLocked_DrainDeadline(t *testing.T) {
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
|
var starts atomic.Int64
|
||||||
|
process := &ProcessMock{
|
||||||
|
StartMock: func(_ *slog.Logger) error {
|
||||||
|
starts.Add(1)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
StopMock: func(_ *slog.Logger) error { return nil },
|
||||||
|
HealthyMock: func(_ *slog.Logger) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
// A concurrency of 2 makes the drain acquire one slot on top of the one the
|
||||||
|
// triggering task hands over. Fill the semaphore so it never can, mimicking
|
||||||
|
// a concurrent task that never completes.
|
||||||
|
ps := NewProcessSupervisor(logger, "test", process, 1, 0, 2, 0).(*processSupervisor)
|
||||||
|
ps.semaphore <- struct{}{}
|
||||||
|
ps.semaphore <- struct{}{}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := ps.doRestartLocked(ctx, restartReasonMaxRequests)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected the drain to fail on the context deadline")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ps.isRestarting.Load() {
|
||||||
|
t.Fatal("expected isRestarting to be cleared after a failed drain")
|
||||||
|
}
|
||||||
|
|
||||||
|
if starts.Load() != 0 {
|
||||||
|
t.Fatalf("expected no restart attempt after a failed drain but got %d", starts.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessSupervisor_maybeRestartAfterTask_Bounded verifies that the eager
|
||||||
|
// restart runs under a deadline. A concurrent task that never completes blocks
|
||||||
|
// the drain, and without a bound the restart goroutine would wait forever with
|
||||||
|
// isRestarting pinned, leaving Healthy() reporting a planned restart for good.
|
||||||
|
func TestProcessSupervisor_maybeRestartAfterTask_Bounded(t *testing.T) {
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
|
process := &ProcessMock{
|
||||||
|
StartMock: func(_ *slog.Logger) error { return nil },
|
||||||
|
StopMock: func(_ *slog.Logger) error { return nil },
|
||||||
|
HealthyMock: func(_ *slog.Logger) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
ps := NewProcessSupervisor(logger, "test", process, 1, 0, 2, 0).(*processSupervisor)
|
||||||
|
ps.eagerRestartTimeout = 100 * time.Millisecond
|
||||||
|
ps.firstStart.Store(true)
|
||||||
|
|
||||||
|
// Wedge one slot so the drain, which needs one on top of the slot the
|
||||||
|
// triggering task hands over, can never complete.
|
||||||
|
ps.semaphore <- struct{}{}
|
||||||
|
|
||||||
|
err := ps.Run(context.Background(), logger, func() error { return nil })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
waitFor := func(what string, want bool) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for ps.isRestarting.Load() != want {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("timed out waiting for the eager restart to %s", what)
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
waitFor("start", true)
|
||||||
|
waitFor("give up on its deadline", false)
|
||||||
|
}
|
||||||
|
|
||||||
// TestProcessSupervisor_Healthy_CachesPositiveResult verifies that a
|
// TestProcessSupervisor_Healthy_CachesPositiveResult verifies that a
|
||||||
// successful probe is cached for [healthCheckCacheTTL] so subsequent
|
// successful probe is cached for [healthCheckCacheTTL] so subsequent
|
||||||
// supervisor.Healthy() calls do not re-issue the underlying process
|
// supervisor.Healthy() calls do not re-issue the underlying process
|
||||||
@@ -272,7 +516,7 @@ func TestProcessSupervisor_Healthy_CachesPositiveResult(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
|
||||||
ps.firstStart.Store(true)
|
ps.firstStart.Store(true)
|
||||||
|
|
||||||
for range 5 {
|
for range 5 {
|
||||||
@@ -302,7 +546,7 @@ func TestProcessSupervisor_Healthy_DoesNotCacheNegativeResult(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
|
||||||
ps.firstStart.Store(true)
|
ps.firstStart.Store(true)
|
||||||
|
|
||||||
processHealthy.Store(false)
|
processHealthy.Store(false)
|
||||||
@@ -489,7 +733,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit, tc.maxQueueSize, 1, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, "test", process, tc.maxReqLimit, tc.maxQueueSize, 1, 0).(*processSupervisor)
|
||||||
if tc.initiallyStarted {
|
if tc.initiallyStarted {
|
||||||
ps.firstStart.Store(true)
|
ps.firstStart.Store(true)
|
||||||
}
|
}
|
||||||
@@ -573,7 +817,7 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
|
|||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.scenario, func(t *testing.T) {
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
ps := NewProcessSupervisor(slog.New(slog.DiscardHandler), new(ProcessMock), 0, 0, 1, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(slog.New(slog.DiscardHandler), "test", new(ProcessMock), 0, 0, 1, 0).(*processSupervisor)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||||
if tc.ctxDone {
|
if tc.ctxDone {
|
||||||
@@ -607,7 +851,7 @@ func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
|
|||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, 0).(*processSupervisor)
|
||||||
|
|
||||||
// Simulating a lock.
|
// Simulating a lock.
|
||||||
ps.semaphore <- struct{}{}
|
ps.semaphore <- struct{}{}
|
||||||
@@ -668,7 +912,7 @@ func TestProcessSupervisor_QueueSizeCAS(t *testing.T) {
|
|||||||
|
|
||||||
maxQueueSize := int64(50)
|
maxQueueSize := int64(50)
|
||||||
// maxConcurrency=1 so all goroutines block on the semaphore, exercising queue logic.
|
// maxConcurrency=1 so all goroutines block on the semaphore, exercising queue logic.
|
||||||
ps := NewProcessSupervisor(logger, process, 0, maxQueueSize, 1, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, "test", process, 0, maxQueueSize, 1, 0).(*processSupervisor)
|
||||||
|
|
||||||
// Simulating a lock so that all goroutines queue up.
|
// Simulating a lock so that all goroutines queue up.
|
||||||
ps.semaphore <- struct{}{}
|
ps.semaphore <- struct{}{}
|
||||||
@@ -722,7 +966,7 @@ func TestProcessSupervisor_QueueSizeIncludesActiveTasks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// maxQueueSize=1, maxConcurrency=1: only one request at a time.
|
// maxQueueSize=1, maxConcurrency=1: only one request at a time.
|
||||||
ps := NewProcessSupervisor(logger, process, 0, 1, 1, 0)
|
ps := NewProcessSupervisor(logger, "test", process, 0, 1, 1, 0)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -811,7 +1055,7 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, 0).(*processSupervisor)
|
||||||
ps.restartsCounter.Store(tc.initialRestartsCount)
|
ps.restartsCounter.Store(tc.initialRestartsCount)
|
||||||
|
|
||||||
for i := 0; i < tc.restartAttempts; i++ {
|
for i := 0; i < tc.restartAttempts; i++ {
|
||||||
@@ -844,7 +1088,7 @@ func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
maxConcurrency := int64(3)
|
maxConcurrency := int64(3)
|
||||||
ps := NewProcessSupervisor(logger, process, 0, 0, maxConcurrency, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, "test", process, 0, 0, maxConcurrency, 0).(*processSupervisor)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -906,7 +1150,7 @@ func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
maxConcurrency := int64(3)
|
maxConcurrency := int64(3)
|
||||||
ps := NewProcessSupervisor(logger, process, 3, 0, maxConcurrency, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, "test", process, 3, 0, maxConcurrency, 0).(*processSupervisor)
|
||||||
ps.firstStart.Store(true)
|
ps.firstStart.Store(true)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
@@ -963,7 +1207,7 @@ func TestProcessSupervisor_IdleShutdown(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
idleTimeout := 50 * time.Millisecond
|
idleTimeout := 50 * time.Millisecond
|
||||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, idleTimeout).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, idleTimeout).(*processSupervisor)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
err := ps.Run(ctx, logger, func() error {
|
err := ps.Run(ctx, logger, func() error {
|
||||||
@@ -1023,7 +1267,7 @@ func TestProcessSupervisor_RetryAfterFailedFirstStart(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, 0).(*processSupervisor)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -1069,7 +1313,7 @@ func TestProcessSupervisor_IdleShutdownSkippedWhenActive(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
idleTimeout := 50 * time.Millisecond
|
idleTimeout := 50 * time.Millisecond
|
||||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, idleTimeout)
|
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, idleTimeout)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
go func() {
|
go func() {
|
||||||
@@ -1091,3 +1335,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ type Api struct {
|
|||||||
correlationIdHeader string
|
correlationIdHeader string
|
||||||
basicAuthUsername string
|
basicAuthUsername string
|
||||||
basicAuthPassword string
|
basicAuthPassword string
|
||||||
|
oidcEnabled bool
|
||||||
|
oidcIssuer string
|
||||||
|
oidcAudience string
|
||||||
|
oidcJwksUrl string
|
||||||
downloadFromCfg downloadFromConfig
|
downloadFromCfg downloadFromConfig
|
||||||
disableHealthCheckRouteTelemetry bool
|
disableHealthCheckRouteTelemetry bool
|
||||||
disableRootRouteTelemetry bool
|
disableRootRouteTelemetry bool
|
||||||
@@ -57,12 +61,15 @@ 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
|
||||||
|
maxConcurrency int
|
||||||
|
maxEntries 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].
|
||||||
@@ -197,11 +204,18 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths")
|
fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths")
|
||||||
fs.String("api-correlation-id-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
|
fs.String("api-correlation-id-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
|
||||||
fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables")
|
fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables")
|
||||||
fs.StringSlice("api-download-from-allow-list", []string{}, "Set the allowed URLs for the download from feature using regular expressions - supports multiple values")
|
fs.Bool("api-enable-oidc-auth", false, "Enable OIDC bearer token authentication - mutually exclusive with basic authentication")
|
||||||
|
fs.String("api-oidc-issuer", "", "Set the OIDC issuer URL, e.g. https://tenant.example.com/ - the token 'iss' claim must match")
|
||||||
|
fs.String("api-oidc-audience", "", "Set the expected OIDC audience - the token 'aud' claim must contain it")
|
||||||
|
fs.String("api-oidc-jwks-url", "", "Set the OIDC JWKS URL - discovered from the issuer's well-known configuration when empty")
|
||||||
|
fs.StringSlice("api-download-from-allow-list", []string{}, `Set the allowed URLs for the download from feature using regular expressions - supports multiple values. A match bypasses --api-download-from-deny-private-ips (API_DOWNLOAD_FROM_DENY_PRIVATE_IPS) and --api-download-from-deny-public-ips (API_DOWNLOAD_FROM_DENY_PUBLIC_IPS), so terminate the host or the pattern also matches suffix hosts, for example ^https?://internal\.svc(:|/|$)`)
|
||||||
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.Int("api-download-from-max-concurrency", 10, "Set the maximum number of downloadFrom entries fetched concurrently per request - bounds the outbound fan-out. Set to 0 to disable this feature")
|
||||||
|
fs.Int("api-download-from-max-entries", 0, "Set the maximum number of downloadFrom entries allowed per request. Set to 0 to disable this 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")
|
||||||
fs.Bool("api-disable-root-route-telemetry", true, "Disable telemetry for the root route")
|
fs.Bool("api-disable-root-route-telemetry", true, "Disable telemetry for the root route")
|
||||||
@@ -239,12 +253,15 @@ 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"),
|
||||||
|
maxConcurrency: flags.MustInt("api-download-from-max-concurrency"),
|
||||||
|
maxEntries: flags.MustInt("api-download-from-max-entries"),
|
||||||
|
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")
|
||||||
@@ -277,6 +294,15 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
|||||||
a.basicAuthPassword = basicAuthPassword
|
a.basicAuthPassword = basicAuthPassword
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enable OIDC auth? The flags are populated from their API_OIDC_* env vars
|
||||||
|
// by the CLI, so no manual environment lookup is needed here.
|
||||||
|
a.oidcEnabled = flags.MustBool("api-enable-oidc-auth")
|
||||||
|
if a.oidcEnabled {
|
||||||
|
a.oidcIssuer = flags.MustString("api-oidc-issuer")
|
||||||
|
a.oidcAudience = flags.MustString("api-oidc-audience")
|
||||||
|
a.oidcJwksUrl = flags.MustString("api-oidc-jwks-url")
|
||||||
|
}
|
||||||
|
|
||||||
// Get routes from modules.
|
// Get routes from modules.
|
||||||
mods, err := ctx.Modules(new(Router))
|
mods, err := ctx.Modules(new(Router))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -357,12 +383,37 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
|||||||
// Logger.
|
// Logger.
|
||||||
a.logger = gotenberg.Logger(a)
|
a.logger = gotenberg.Logger(a)
|
||||||
|
|
||||||
|
a.warnInsecureDebugRoute()
|
||||||
|
|
||||||
// File system.
|
// File system.
|
||||||
a.fs = gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
a.fs = gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// warnInsecureDebugRoute logs a warning when the debug route is reachable
|
||||||
|
// without authentication.
|
||||||
|
//
|
||||||
|
// The route reports the resolved configuration of every module, which is
|
||||||
|
// useful to an operator and equally useful to anyone else who can reach it.
|
||||||
|
// This warns rather than refuses: an operator may sit behind a gateway that
|
||||||
|
// authenticates on Gotenberg's behalf, and failing startup would break them.
|
||||||
|
func (a *Api) warnInsecureDebugRoute() {
|
||||||
|
if !a.enableDebugRoute || a.basicAuthUsername != "" || a.oidcEnabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.logger == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.logger.WarnContext(
|
||||||
|
context.Background(),
|
||||||
|
"--api-enable-debug-route (API_ENABLE_DEBUG_ROUTE) is enabled but no authentication is configured, so anyone who can reach Gotenberg can read its configuration. Set --api-enable-basic-auth (API_ENABLE_BASIC_AUTH) with GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD, set --api-enable-oidc-auth (API_ENABLE_OIDC_AUTH), or disable the route.",
|
||||||
|
slog.String("flag", "--api-enable-debug-route"),
|
||||||
|
slog.String("env", "API_ENABLE_DEBUG_ROUTE"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Validate validates the module properties.
|
// Validate validates the module properties.
|
||||||
func (a *Api) Validate() error {
|
func (a *Api) Validate() error {
|
||||||
var err error
|
var err error
|
||||||
@@ -377,6 +428,25 @@ func (a *Api) Validate() error {
|
|||||||
err = errors.Join(err, errors.New("IP must be a valid IP address"))
|
err = errors.Join(err, errors.New("IP must be a valid IP address"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if a.downloadFromCfg.enableEnvironmentProxy {
|
||||||
|
proxyErr := gotenberg.ValidateEnvironmentProxyVariables()
|
||||||
|
if proxyErr != nil {
|
||||||
|
err = errors.Join(err, fmt.Errorf("--api-download-from-enable-environment-proxy is set: %w", proxyErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.downloadFromCfg.maxConcurrency < 0 {
|
||||||
|
err = errors.Join(err,
|
||||||
|
fmt.Errorf("download from max concurrency must not be negative, got %d; set --api-download-from-max-concurrency (env API_DOWNLOAD_FROM_MAX_CONCURRENCY) to 0 to disable the limit", a.downloadFromCfg.maxConcurrency),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.downloadFromCfg.maxEntries < 0 {
|
||||||
|
err = errors.Join(err,
|
||||||
|
fmt.Errorf("download from max entries must not be negative, got %d; set --api-download-from-max-entries (env API_DOWNLOAD_FROM_MAX_ENTRIES) to 0 to disable the limit", a.downloadFromCfg.maxEntries),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") {
|
if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") {
|
||||||
err = errors.Join(err,
|
err = errors.Join(err,
|
||||||
errors.New("both TLS certificate and key files must be set"),
|
errors.New("both TLS certificate and key files must be set"),
|
||||||
@@ -401,6 +471,25 @@ func (a *Api) Validate() error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if a.basicAuthUsername != "" && a.oidcEnabled {
|
||||||
|
err = errors.Join(err,
|
||||||
|
errors.New("basic authentication and OIDC authentication cannot both be enabled"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.oidcEnabled {
|
||||||
|
if a.oidcIssuer == "" {
|
||||||
|
err = errors.Join(err,
|
||||||
|
errors.New("OIDC issuer must not be empty when OIDC auth is enabled; set --api-oidc-issuer"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if a.oidcAudience == "" {
|
||||||
|
err = errors.Join(err,
|
||||||
|
errors.New("OIDC audience must not be empty when OIDC auth is enabled; set --api-oidc-audience"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -507,11 +596,18 @@ func (a *Api) Start() error {
|
|||||||
|
|
||||||
hardTimeout := a.timeout + (time.Duration(5) * time.Second)
|
hardTimeout := a.timeout + (time.Duration(5) * time.Second)
|
||||||
|
|
||||||
// Basic auth?
|
// Authentication?
|
||||||
var securityMiddleware echo.MiddlewareFunc
|
var securityMiddleware echo.MiddlewareFunc
|
||||||
if a.basicAuthUsername != "" {
|
switch {
|
||||||
|
case a.basicAuthUsername != "":
|
||||||
securityMiddleware = basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword)
|
securityMiddleware = basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword)
|
||||||
} else {
|
case a.oidcEnabled:
|
||||||
|
verifier, err := a.buildOidcVerifier()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build OIDC verifier: %w", err)
|
||||||
|
}
|
||||||
|
securityMiddleware = oidcAuthMiddleware(verifier)
|
||||||
|
default:
|
||||||
securityMiddleware = func(next echo.HandlerFunc) echo.HandlerFunc {
|
securityMiddleware = func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
return next(c)
|
return next(c)
|
||||||
|
|||||||
134
pkg/modules/api/api_test.go
Normal file
134
pkg/modules/api/api_test.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApi_Validate_Auth(t *testing.T) {
|
||||||
|
base := func() *Api {
|
||||||
|
return &Api{port: 3000, rootPath: "/", correlationIdHeader: "Gotenberg-Trace"}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
mutate func(*Api)
|
||||||
|
wantErr string // substring expected in the error, "" means no error
|
||||||
|
}{
|
||||||
|
{"no auth", func(*Api) {}, ""},
|
||||||
|
{"basic auth only", func(a *Api) { a.basicAuthUsername = "foo" }, ""},
|
||||||
|
{
|
||||||
|
"oidc auth valid",
|
||||||
|
func(a *Api) {
|
||||||
|
a.oidcEnabled = true
|
||||||
|
a.oidcIssuer = "https://tenant.example.com/"
|
||||||
|
a.oidcAudience = "gotenberg"
|
||||||
|
},
|
||||||
|
"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"basic and oidc are mutually exclusive",
|
||||||
|
func(a *Api) {
|
||||||
|
a.basicAuthUsername = "foo"
|
||||||
|
a.oidcEnabled = true
|
||||||
|
a.oidcIssuer = "https://tenant.example.com/"
|
||||||
|
a.oidcAudience = "gotenberg"
|
||||||
|
},
|
||||||
|
"cannot both be enabled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"oidc missing issuer",
|
||||||
|
func(a *Api) { a.oidcEnabled = true; a.oidcAudience = "gotenberg" },
|
||||||
|
"issuer must not be empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"oidc missing audience",
|
||||||
|
func(a *Api) { a.oidcEnabled = true; a.oidcIssuer = "https://tenant.example.com/" },
|
||||||
|
"audience must not be empty",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
a := base()
|
||||||
|
tc.mutate(a)
|
||||||
|
|
||||||
|
err := a.Validate()
|
||||||
|
|
||||||
|
if tc.wantErr == "" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||||
|
t.Fatalf("error = %v, want a substring %q", err, tc.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApi_warnInsecureDebugRoute(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
api Api
|
||||||
|
expectWarn bool
|
||||||
|
expectFields []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "debug route on with no auth warns",
|
||||||
|
api: Api{enableDebugRoute: true},
|
||||||
|
expectWarn: true,
|
||||||
|
expectFields: []string{"--api-enable-debug-route", "API_ENABLE_DEBUG_ROUTE", "--api-enable-basic-auth", "API_ENABLE_BASIC_AUTH", "--api-enable-oidc-auth", "API_ENABLE_OIDC_AUTH"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "debug route off is silent",
|
||||||
|
api: Api{enableDebugRoute: false},
|
||||||
|
expectWarn: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "basic auth silences it",
|
||||||
|
api: Api{enableDebugRoute: true, basicAuthUsername: "foo"},
|
||||||
|
expectWarn: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "oidc silences it",
|
||||||
|
api: Api{enableDebugRoute: true, oidcEnabled: true},
|
||||||
|
expectWarn: false,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
tc.api.logger = slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||||
|
|
||||||
|
tc.api.warnInsecureDebugRoute()
|
||||||
|
|
||||||
|
logged := buf.String()
|
||||||
|
if !tc.expectWarn {
|
||||||
|
if logged != "" {
|
||||||
|
t.Fatalf("expected no warning, got: %s", logged)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if logged == "" {
|
||||||
|
t.Fatal("expected a warning, got none")
|
||||||
|
}
|
||||||
|
// Every flag named must carry its environment variable.
|
||||||
|
for _, want := range tc.expectFields {
|
||||||
|
if !strings.Contains(logged, want) {
|
||||||
|
t.Fatalf("warning does not mention %q: %s", want, logged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(logged, "—") {
|
||||||
|
t.Fatalf("warning must not contain an em dash: %s", logged)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The warning reads a.logger, which is nil until Provision assigns it.
|
||||||
|
func TestApi_warnInsecureDebugRoute_NilLoggerDoesNotPanic(t *testing.T) {
|
||||||
|
api := Api{enableDebugRoute: true}
|
||||||
|
api.warnInsecureDebugRoute()
|
||||||
|
}
|
||||||
@@ -51,6 +51,24 @@ type Context struct {
|
|||||||
outputPaths []string
|
outputPaths []string
|
||||||
cancelled bool
|
cancelled bool
|
||||||
|
|
||||||
|
// fileOrder records the order files were received in, keyed by disk path.
|
||||||
|
// It breaks ties when two uploads share an original filename, so that
|
||||||
|
// de-duplicated files keep their upload order instead of being ordered by
|
||||||
|
// the suffix uniqueFilename added.
|
||||||
|
fileOrder map[string]int
|
||||||
|
|
||||||
|
// fileBase maps a disk path to the original filename as received, before
|
||||||
|
// de-duplication. Sorting on it keeps a de-duplicated file next to its
|
||||||
|
// twin rather than wherever its numbered name would land.
|
||||||
|
fileBase map[string]string
|
||||||
|
|
||||||
|
// outputFilename is the sanitized Gotenberg-Output-Filename header,
|
||||||
|
// snapshotted while the [echo.Context] is still live. Echo returns that
|
||||||
|
// context to a pool as soon as the handler returns, and an asynchronous
|
||||||
|
// conversion outlives it, so reading the header from the pooled store later
|
||||||
|
// yields whichever request happens to own it by then.
|
||||||
|
outputFilename string
|
||||||
|
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
echoCtx echo.Context
|
echoCtx echo.Context
|
||||||
mkdirAll gotenberg.MkdirAll
|
mkdirAll gotenberg.MkdirAll
|
||||||
@@ -80,6 +98,48 @@ func (t *trackingReader) Read(p []byte) (int, error) {
|
|||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// errTooManyDownloadFromEntries is returned by [decodeDownloadFrom] when the
|
||||||
|
// array holds more entries than the configured maximum.
|
||||||
|
var errTooManyDownloadFromEntries = errors.New("too many downloadFrom entries")
|
||||||
|
|
||||||
|
// decodeDownloadFrom decodes the downloadFrom form field, refusing to
|
||||||
|
// accumulate more than maxEntries. A maxEntries of 0 means no limit.
|
||||||
|
//
|
||||||
|
// It decodes element by element rather than calling [json.Unmarshal] on the
|
||||||
|
// whole value. A compact array such as "[{},{},{}]" costs three bytes per
|
||||||
|
// entry on the wire and expands to roughly seventy times that once
|
||||||
|
// unmarshalled, so counting the entries afterwards is too late to bound the
|
||||||
|
// allocation. Streaming keeps the cost proportional to maxEntries no matter
|
||||||
|
// how long the array is.
|
||||||
|
func decodeDownloadFrom(raw string, maxEntries int) ([]downloadFrom, error) {
|
||||||
|
dec := json.NewDecoder(strings.NewReader(raw))
|
||||||
|
|
||||||
|
token, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if delim, ok := token.(json.Delim); !ok || delim != '[' {
|
||||||
|
return nil, fmt.Errorf("expected a JSON array, got '%v'", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dls []downloadFrom
|
||||||
|
for dec.More() {
|
||||||
|
if maxEntries > 0 && len(dls) >= maxEntries {
|
||||||
|
return nil, errTooManyDownloadFromEntries
|
||||||
|
}
|
||||||
|
|
||||||
|
var dl downloadFrom
|
||||||
|
err = dec.Decode(&dl)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dls = append(dls, dl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dls, nil
|
||||||
|
}
|
||||||
|
|
||||||
type downloadFrom struct {
|
type downloadFrom struct {
|
||||||
// Url is the URL to download a file from.
|
// Url is the URL to download a file from.
|
||||||
Url string `json:"url"`
|
Url string `json:"url"`
|
||||||
@@ -111,20 +171,24 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snapshot now, while echoCtx still belongs to this request.
|
||||||
|
outputFilename, _ := echoCtx.Get("outputFilename").(string)
|
||||||
|
|
||||||
ctx := &Context{
|
ctx := &Context{
|
||||||
outputPaths: make([]string, 0),
|
outputPaths: make([]string, 0),
|
||||||
cancelled: false,
|
cancelled: false,
|
||||||
logger: logger,
|
outputFilename: outputFilename,
|
||||||
echoCtx: echoCtx,
|
logger: logger,
|
||||||
mkdirAll: new(gotenberg.OsMkdirAll),
|
echoCtx: echoCtx,
|
||||||
pathRename: new(gotenberg.OsPathRename),
|
mkdirAll: new(gotenberg.OsMkdirAll),
|
||||||
Context: processCtx,
|
pathRename: new(gotenberg.OsPathRename),
|
||||||
|
Context: processCtx,
|
||||||
}
|
}
|
||||||
|
|
||||||
// A custom cancel function which removes the context's working directory
|
// A custom cancel function which removes the context's working directory
|
||||||
@@ -178,6 +242,12 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
|
|
||||||
return nil, cancel, fmt.Errorf("get multipart form: %w", err)
|
return nil, cancel, fmt.Errorf("get multipart form: %w", err)
|
||||||
}
|
}
|
||||||
|
defer func() {
|
||||||
|
err := form.RemoveAll()
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorContext(context.Background(), fmt.Sprintf("remove multipart temporary files: %s", err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// This will ensure we do not exceed the body limit.
|
// This will ensure we do not exceed the body limit.
|
||||||
var formValuesSize int64
|
var formValuesSize int64
|
||||||
@@ -207,8 +277,13 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
// any.
|
// any.
|
||||||
raw, ok := ctx.values["downloadFrom"]
|
raw, ok := ctx.values["downloadFrom"]
|
||||||
if !downloadFromCfg.disable && ok {
|
if !downloadFromCfg.disable && ok {
|
||||||
var dls []downloadFrom
|
dls, err := decodeDownloadFrom(raw[0], downloadFromCfg.maxEntries)
|
||||||
err = json.Unmarshal([]byte(raw[0]), &dls)
|
if errors.Is(err, errTooManyDownloadFromEntries) {
|
||||||
|
return nil, cancel, WrapError(
|
||||||
|
fmt.Errorf("decode downloadFrom: %w", err),
|
||||||
|
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'downloadFrom' form field value: too many entries, the maximum is %d", downloadFromCfg.maxEntries)),
|
||||||
|
)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, cancel, WrapError(
|
return nil, cancel, WrapError(
|
||||||
fmt.Errorf("unmarshal json: %w", err),
|
fmt.Errorf("unmarshal json: %w", err),
|
||||||
@@ -226,6 +301,13 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
results := make([]downloadFromResult, len(dls))
|
results := make([]downloadFromResult, len(dls))
|
||||||
|
|
||||||
eg, _ := errgroup.WithContext(ctx)
|
eg, _ := errgroup.WithContext(ctx)
|
||||||
|
// Bound the number of in-flight downloads. Each entry allocates a
|
||||||
|
// retryable client, an outbound transport, a span, and logger state,
|
||||||
|
// so an unbounded array would otherwise exhaust process memory. A
|
||||||
|
// value of 0 keeps the fan-out unbounded.
|
||||||
|
if downloadFromCfg.maxConcurrency > 0 {
|
||||||
|
eg.SetLimit(downloadFromCfg.maxConcurrency)
|
||||||
|
}
|
||||||
for i, dl := range dls {
|
for i, dl := range dls {
|
||||||
eg.Go(func() error {
|
eg.Go(func() error {
|
||||||
deadline, ok := ctx.Deadline()
|
deadline, ok := ctx.Deadline()
|
||||||
@@ -257,7 +339,12 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
|
|
||||||
logger.DebugContext(dlCtx, fmt.Sprintf("download file from '%s'", dl.Url))
|
logger.DebugContext(dlCtx, fmt.Sprintf("download file from '%s'", dl.Url))
|
||||||
|
|
||||||
req, err := retryablehttp.NewRequest(http.MethodGet, dl.Url, nil)
|
// The request must carry dlCtx: retryablehttp.NewRequest builds
|
||||||
|
// on context.Background(), and its wait between attempts is a
|
||||||
|
// select on the request context, so a contextless request cannot
|
||||||
|
// be interrupted by --api-timeout (env API_TIMEOUT) or by the
|
||||||
|
// caller going away.
|
||||||
|
req, err := retryablehttp.NewRequestWithContext(dlCtx, http.MethodGet, dl.Url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
dlSpan.RecordError(err)
|
dlSpan.RecordError(err)
|
||||||
dlSpan.SetStatus(codes.Error, err.Error())
|
dlSpan.SetStatus(codes.Error, err.Error())
|
||||||
@@ -280,14 +367,28 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Entries are serialized by the concurrency limit above, so a
|
||||||
|
// late one can start after the deadline has already passed.
|
||||||
|
// Fail closed rather than derive a non-positive timeout, which
|
||||||
|
// [http.Client] reads as no deadline at all.
|
||||||
|
remaining := time.Until(deadline)
|
||||||
|
if remaining <= 0 {
|
||||||
|
dlSpan.RecordError(context.DeadlineExceeded)
|
||||||
|
dlSpan.SetStatus(codes.Error, context.DeadlineExceeded.Error())
|
||||||
|
dlSpan.End()
|
||||||
|
return fmt.Errorf("download file from '%s': %w", dl.Url, context.DeadlineExceeded)
|
||||||
|
}
|
||||||
|
|
||||||
client := &retryablehttp.Client{
|
client := &retryablehttp.Client{
|
||||||
HTTPClient: gotenberg.NewOutboundHttpClient(time.Until(deadline), downloadFromCfg.allowList, downloadFromCfg.denyList, ipOpts...),
|
HTTPClient: gotenberg.NewOutboundHttpClient(remaining, 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: remaining,
|
||||||
Logger: gotenberg.NewLeveledLogger(logger),
|
Logger: gotenberg.NewLeveledLogger(logger),
|
||||||
CheckRetry: retryablehttp.DefaultRetryPolicy,
|
CheckRetry: retryablehttp.DefaultRetryPolicy,
|
||||||
Backoff: retryablehttp.DefaultBackoff,
|
// Not DefaultBackoff: it hands a hostile origin control of
|
||||||
|
// the wait via Retry-After.
|
||||||
|
Backoff: gotenberg.ClampedBackoff,
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
@@ -295,6 +396,17 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
dlSpan.RecordError(err)
|
dlSpan.RecordError(err)
|
||||||
dlSpan.SetStatus(codes.Error, err.Error())
|
dlSpan.SetStatus(codes.Error, err.Error())
|
||||||
dlSpan.End()
|
dlSpan.End()
|
||||||
|
|
||||||
|
// A redirect target is filtered inside the client, so the
|
||||||
|
// policy verdict surfaces here rather than from the
|
||||||
|
// pre-flight above. Keep it out of the response: the first
|
||||||
|
// hop answers a filtered URL with a generic 403, and a
|
||||||
|
// later hop must not describe the allow-list, the deny-list
|
||||||
|
// or the IP policy instead.
|
||||||
|
if errors.Is(err, gotenberg.ErrFiltered) {
|
||||||
|
return fmt.Errorf("download file from '%s': %w", dl.Url, err)
|
||||||
|
}
|
||||||
|
|
||||||
return WrapError(
|
return WrapError(
|
||||||
fmt.Errorf("download file from to '%s': %w", dl.Url, err),
|
fmt.Errorf("download file from to '%s': %w", dl.Url, err),
|
||||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Unable to download file from '%s': %s", dl.Url, err)),
|
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Unable to download file from '%s': %s", dl.Url, err)),
|
||||||
@@ -368,7 +480,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
// 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.
|
||||||
// See: https://github.com/gotenberg/gotenberg/issues/1500.
|
// See: https://github.com/gotenberg/gotenberg/issues/1500.
|
||||||
safeName := uuid.New().String() + filepath.Ext(filename)
|
safeName := uuid.New().String() + safeExt(filename)
|
||||||
path := fmt.Sprintf("%s/%s", ctx.dirPath, safeName)
|
path := fmt.Sprintf("%s/%s", ctx.dirPath, safeName)
|
||||||
|
|
||||||
out, err := os.Create(path)
|
out, err := os.Create(path)
|
||||||
@@ -422,18 +534,20 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
ctx.files[r.filename] = r.path
|
filename := ctx.uniqueFilename(r.filename)
|
||||||
ctx.diskToOriginal[r.path] = r.filename
|
ctx.files[filename] = r.path
|
||||||
|
ctx.diskToOriginal[r.path] = filename
|
||||||
|
ctx.trackFileOrder(r.path, r.filename)
|
||||||
if r.formField != "" {
|
if r.formField != "" {
|
||||||
ctx.filesByField[r.formField] = append(ctx.filesByField[r.formField], r.path)
|
ctx.filesByField[r.formField] = append(ctx.filesByField[r.formField], r.path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
copyToDisk := func(fh *multipart.FileHeader) error {
|
copyToDisk := func(fh *multipart.FileHeader) (string, error) {
|
||||||
in, err := fh.Open()
|
in, err := fh.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("open multipart file: %w", err)
|
return "", fmt.Errorf("open multipart file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -456,12 +570,12 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
// 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.
|
||||||
// See: https://github.com/gotenberg/gotenberg/issues/1500.
|
// See: https://github.com/gotenberg/gotenberg/issues/1500.
|
||||||
safeName := uuid.New().String() + filepath.Ext(filename)
|
safeName := uuid.New().String() + safeExt(filename)
|
||||||
path := fmt.Sprintf("%s/%s", ctx.dirPath, safeName)
|
path := fmt.Sprintf("%s/%s", ctx.dirPath, safeName)
|
||||||
|
|
||||||
out, err := os.Create(path)
|
out, err := os.Create(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create local file: %w", err)
|
return "", fmt.Errorf("create local file: %w", err)
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
err := out.Close()
|
err := out.Close()
|
||||||
@@ -472,26 +586,28 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
|
|
||||||
_, err = io.Copy(out, reader)
|
_, err = io.Copy(out, reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("copy multipart file to local file: %w", err)
|
return "", fmt.Errorf("copy multipart file to local file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
base := filename
|
||||||
|
filename = ctx.uniqueFilename(filename)
|
||||||
ctx.files[filename] = path
|
ctx.files[filename] = path
|
||||||
ctx.diskToOriginal[path] = filename
|
ctx.diskToOriginal[path] = filename
|
||||||
|
ctx.trackFileOrder(path, base)
|
||||||
|
|
||||||
return nil
|
return filename, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then, copy the form files, if any.
|
// Then, copy the form files, if any.
|
||||||
for fieldName, files := range form.File {
|
for fieldName, files := range form.File {
|
||||||
for _, fh := range files {
|
for _, fh := range files {
|
||||||
err = copyToDisk(fh)
|
filename, errCopy := copyToDisk(fh)
|
||||||
if err != nil {
|
if errCopy != nil {
|
||||||
return ctx, cancel, fmt.Errorf("copy to disk: %w", err)
|
return ctx, cancel, fmt.Errorf("copy to disk: %w", errCopy)
|
||||||
}
|
}
|
||||||
// Track files by field name
|
// Track files by field name, under the name copyToDisk actually
|
||||||
filename := sanitizeFilename(fh.Filename)
|
// stored, which may be a de-duplicated variant.
|
||||||
filePath := ctx.files[filename]
|
ctx.filesByField[fieldName] = append(ctx.filesByField[fieldName], ctx.files[filename])
|
||||||
ctx.filesByField[fieldName] = append(ctx.filesByField[fieldName], filePath)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,9 +622,9 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
if symlinkPath == diskPath {
|
if symlinkPath == diskPath {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
err = os.Symlink(filepath.Base(diskPath), symlinkPath)
|
errSymlink := os.Symlink(filepath.Base(diskPath), symlinkPath)
|
||||||
if err != nil {
|
if errSymlink != nil {
|
||||||
logger.DebugContext(context.Background(), fmt.Sprintf("skip symlink for '%s': %s", originalName, err))
|
logger.DebugContext(context.Background(), fmt.Sprintf("skip symlink for '%s': %s", originalName, errSymlink))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,7 +633,10 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
ctx.Log().DebugContext(ctx, fmt.Sprintf("form files by field: %+v", ctx.filesByField))
|
ctx.Log().DebugContext(ctx, fmt.Sprintf("form files by field: %+v", ctx.filesByField))
|
||||||
ctx.Log().DebugContext(ctx, fmt.Sprintf("total bytes: %d", totalBytesRead.Load()))
|
ctx.Log().DebugContext(ctx, fmt.Sprintf("total bytes: %d", totalBytesRead.Load()))
|
||||||
|
|
||||||
return ctx, cancel, err
|
// Explicitly nil: the best-effort symlink loop above must not decide the
|
||||||
|
// outcome of the request. Its failure used to escape here as a bare 500,
|
||||||
|
// non-deterministically, because ctx.files iterates in random order.
|
||||||
|
return ctx, cancel, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request returns the [http.Request].
|
// Request returns the [http.Request].
|
||||||
@@ -532,10 +651,17 @@ func (ctx *Context) FormData() *FormData {
|
|||||||
files: ctx.files,
|
files: ctx.files,
|
||||||
filesByField: ctx.filesByField,
|
filesByField: ctx.filesByField,
|
||||||
diskToOriginal: ctx.diskToOriginal,
|
diskToOriginal: ctx.diskToOriginal,
|
||||||
|
fileOrder: ctx.fileOrder,
|
||||||
|
fileBase: ctx.fileBase,
|
||||||
errors: nil,
|
errors: nil,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
@@ -567,7 +693,7 @@ func (ctx *Context) GeneratePath(extension string) string {
|
|||||||
// limits but registers the given filename so that [Context.OriginalFilename]
|
// limits but registers the given filename so that [Context.OriginalFilename]
|
||||||
// can resolve it. It does not create a file.
|
// can resolve it. It does not create a file.
|
||||||
func (ctx *Context) GeneratePathFromFilename(filename string) string {
|
func (ctx *Context) GeneratePathFromFilename(filename string) string {
|
||||||
safeName := uuid.New().String() + filepath.Ext(filename)
|
safeName := uuid.New().String() + safeExt(filename)
|
||||||
path := fmt.Sprintf("%s/%s", ctx.dirPath, safeName)
|
path := fmt.Sprintf("%s/%s", ctx.dirPath, safeName)
|
||||||
ctx.diskToOriginal[path] = filename
|
ctx.diskToOriginal[path] = filename
|
||||||
return path
|
return path
|
||||||
@@ -670,15 +796,72 @@ func (ctx *Context) BuildOutputFile() (string, error) {
|
|||||||
// OutputFilename returns the filename based on the given output path or the
|
// OutputFilename returns the filename based on the given output path or the
|
||||||
// "Gotenberg-Output-Filename" header's value.
|
// "Gotenberg-Output-Filename" header's value.
|
||||||
func (ctx *Context) OutputFilename(outputPath string) string {
|
func (ctx *Context) OutputFilename(outputPath string) string {
|
||||||
filename := ctx.echoCtx.Get("outputFilename").(string)
|
if ctx.outputFilename == "" {
|
||||||
|
|
||||||
if filename == "" {
|
|
||||||
return ctx.OriginalFilename(outputPath)
|
return ctx.OriginalFilename(outputPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
filename := ctx.outputFilename
|
||||||
|
|
||||||
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))
|
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxDiskExtLength bounds the extension copied onto a UUID-based disk name.
|
||||||
|
// The UUID stem is 36 characters, so a longer extension risks NAME_MAX, which
|
||||||
|
// is 255 on ext4 and overlayfs. The untruncated name is kept in
|
||||||
|
// [Context.diskToOriginal], which never reaches the filesystem.
|
||||||
|
const maxDiskExtLength = 32
|
||||||
|
|
||||||
|
// safeExt returns the extension to append to a UUID-based disk name. It drops
|
||||||
|
// an extension too long to be safe rather than let [os.Create] fail with
|
||||||
|
// ENAMETOOLONG, which surfaced to the caller as a bare 500.
|
||||||
|
func safeExt(filename string) string {
|
||||||
|
ext := filepath.Ext(filename)
|
||||||
|
if len(ext) > maxDiskExtLength {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return ext
|
||||||
|
}
|
||||||
|
|
||||||
|
// uniqueFilename returns filename, or a numbered variant of it when the
|
||||||
|
// request already carries a file by that name.
|
||||||
|
//
|
||||||
|
// Uploads are keyed by their sanitized original filename, so two files sharing
|
||||||
|
// one name used to collide: the second overwrote the first and only one
|
||||||
|
// reached the conversion, while both stayed on disk and counted against the
|
||||||
|
// body limit. Sanitizing strips directories, so "a/doc.pdf" and "b/doc.pdf"
|
||||||
|
// collide too.
|
||||||
|
func (ctx *Context) uniqueFilename(filename string) string {
|
||||||
|
_, exists := ctx.files[filename]
|
||||||
|
if !exists {
|
||||||
|
return filename
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := filepath.Ext(filename)
|
||||||
|
stem := strings.TrimSuffix(filename, ext)
|
||||||
|
|
||||||
|
for i := 2; ; i++ {
|
||||||
|
candidate := fmt.Sprintf("%s (%d)%s", stem, i, ext)
|
||||||
|
_, exists = ctx.files[candidate]
|
||||||
|
if !exists {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackFileOrder records where a file arrived in the request and the filename
|
||||||
|
// it arrived under, so [FormData.paths] can order it the way the caller sent
|
||||||
|
// it.
|
||||||
|
func (ctx *Context) trackFileOrder(path, base string) {
|
||||||
|
if ctx.fileOrder == nil {
|
||||||
|
ctx.fileOrder = make(map[string]int)
|
||||||
|
ctx.fileBase = make(map[string]string)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fileOrder[path] = len(ctx.fileOrder)
|
||||||
|
ctx.fileBase[path] = base
|
||||||
|
}
|
||||||
|
|
||||||
// sanitizeFilename strips path separators (including backslashes, which
|
// sanitizeFilename strips path separators (including backslashes, which
|
||||||
// [filepath.Base] ignores on Linux) and control characters from a
|
// [filepath.Base] ignores on Linux) and control characters from a
|
||||||
// caller-supplied filename, then NFC-normalizes the result. This prevents a
|
// caller-supplied filename, then NFC-normalizes the result. This prevents a
|
||||||
|
|||||||
@@ -4,15 +4,22 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/dlclark/regexp2"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
@@ -73,6 +80,64 @@ func TestNewContext_Cancellation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewContext_RemovesMultipartTemporaryFiles(t *testing.T) {
|
||||||
|
body := new(bytes.Buffer)
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
part, err := writer.CreateFormFile("files", "input.odt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create multipart file: %v", err)
|
||||||
|
}
|
||||||
|
_, err = part.Write(bytes.Repeat([]byte("x"), 1024))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write multipart file: %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())
|
||||||
|
err = req.ParseMultipartForm(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse multipart form: %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = req.MultipartForm.RemoveAll()
|
||||||
|
}()
|
||||||
|
|
||||||
|
upload, err := req.MultipartForm.File["files"][0].Open()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open disk-backed multipart file: %v", err)
|
||||||
|
}
|
||||||
|
temporaryFile, ok := upload.(*os.File)
|
||||||
|
if !ok {
|
||||||
|
_ = upload.Close()
|
||||||
|
t.Fatal("multipart upload is not disk-backed")
|
||||||
|
}
|
||||||
|
temporaryPath := temporaryFile.Name()
|
||||||
|
err = temporaryFile.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close disk-backed multipart file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
echoCtx := echo.New().NewContext(req, httptest.NewRecorder())
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
||||||
|
downloadFromCfg := downloadFromConfig{disable: true}
|
||||||
|
|
||||||
|
_, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromCfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newContext returned error: %v", err)
|
||||||
|
}
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err = os.Stat(temporaryPath)
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("multipart temporary file still exists: %s", temporaryPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Concurrent downloadFrom entries must not race on the shared maps
|
// Concurrent downloadFrom entries must not race on the shared maps
|
||||||
// (ctx.files, ctx.diskToOriginal, ctx.filesByField). Run under -race
|
// (ctx.files, ctx.diskToOriginal, ctx.filesByField). Run under -race
|
||||||
// to catch the data race; without -race a sufficient number of entries
|
// to catch the data race; without -race a sufficient number of entries
|
||||||
@@ -150,6 +215,137 @@ func TestNewContext_DownloadFromConcurrentMapWrites(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An oversized downloadFrom array must be rejected at the trust boundary with
|
||||||
|
// a 400, before any download goroutine is spawned.
|
||||||
|
// https://github.com/gotenberg/gotenberg/security/advisories/GHSA-6vqw-2jgm-4x88
|
||||||
|
func TestNewContext_DownloadFromMaxEntries(t *testing.T) {
|
||||||
|
var hits atomic.Int64
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="download.txt"`)
|
||||||
|
_, _ = w.Write([]byte("downloaded"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
dls := make([]downloadFrom, 3)
|
||||||
|
for i := range dls {
|
||||||
|
dls[i] = downloadFrom{Url: fmt.Sprintf("%s/file?i=%d", server.URL, i)}
|
||||||
|
}
|
||||||
|
|
||||||
|
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{maxEntries: 2}
|
||||||
|
|
||||||
|
_, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromCfg)
|
||||||
|
if cancel != nil {
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("newContext returned no error, want a 400 for too many entries")
|
||||||
|
}
|
||||||
|
|
||||||
|
var httpErr HttpError
|
||||||
|
if !errors.As(err, &httpErr) {
|
||||||
|
t.Fatalf("error %v is not an HttpError", err)
|
||||||
|
}
|
||||||
|
if status, _ := httpErr.HttpError(); status != http.StatusBadRequest {
|
||||||
|
t.Fatalf("HTTP status = %d, want %d", status, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
if got := hits.Load(); got != 0 {
|
||||||
|
t.Fatalf("server hits = %d, want 0 (rejected before any download)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The number of in-flight downloadFrom fetches must never exceed the
|
||||||
|
// configured concurrency limit, regardless of array length.
|
||||||
|
// https://github.com/gotenberg/gotenberg/security/advisories/GHSA-6vqw-2jgm-4x88
|
||||||
|
func TestNewContext_DownloadFromMaxConcurrency(t *testing.T) {
|
||||||
|
const (
|
||||||
|
downloads = 8
|
||||||
|
maxConcurrency = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
var current, peak atomic.Int64
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
inFlight := current.Add(1)
|
||||||
|
for {
|
||||||
|
observed := peak.Load()
|
||||||
|
if inFlight <= observed || peak.CompareAndSwap(observed, inFlight) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
current.Add(-1)
|
||||||
|
|
||||||
|
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)}
|
||||||
|
}
|
||||||
|
|
||||||
|
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{maxConcurrency: maxConcurrency}
|
||||||
|
|
||||||
|
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 := peak.Load(); got > maxConcurrency {
|
||||||
|
t.Fatalf("peak concurrency = %d, want <= %d", got, maxConcurrency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSanitizeFilename(t *testing.T) {
|
func TestSanitizeFilename(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
scenario string
|
scenario string
|
||||||
@@ -206,3 +402,503 @@ func TestSanitizeFilename(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestContext_FileCount(t *testing.T) {
|
||||||
|
ctx := &Context{}
|
||||||
|
if got := ctx.FileCount(); got != 0 {
|
||||||
|
t.Errorf("expected 0 files, got %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.files = map[string]string{
|
||||||
|
"index.html": "/work/index.html",
|
||||||
|
"header.html": "/work/header.html",
|
||||||
|
"styles.css": "/work/styles.css",
|
||||||
|
}
|
||||||
|
if got := ctx.FileCount(); got != 3 {
|
||||||
|
t.Errorf("expected 3 files, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A hostile origin must not choose how long Gotenberg waits.
|
||||||
|
// [retryablehttp.DefaultBackoff] returns a Retry-After header verbatim for 429
|
||||||
|
// and 503, and the wait between attempts is a select on the request context.
|
||||||
|
// Building the request without a context therefore pinned the goroutine, its
|
||||||
|
// connection, and its working directory for the attacker's chosen duration,
|
||||||
|
// well past --api-timeout (env API_TIMEOUT).
|
||||||
|
func TestNewContext_DownloadFromHostileRetryAfterIsBounded(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Retry-After", "3600")
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
payload, err := json.Marshal([]downloadFrom{{Url: server.URL + "/file"}})
|
||||||
|
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))
|
||||||
|
|
||||||
|
const timeout = 500 * time.Millisecond
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, cancel, err := newContext(echoCtx, logger, fs, timeout, 0, downloadFromConfig{maxRetry: 2})
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
if cancel != nil {
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected newContext to fail against an origin that only answers 429")
|
||||||
|
}
|
||||||
|
// Generous: the deadline is 500ms and Retry-After asks for an hour. Any
|
||||||
|
// value in seconds means the remote is still in control.
|
||||||
|
if elapsed > 10*time.Second {
|
||||||
|
t.Fatalf("newContext took %s with Retry-After 3600; --api-timeout must bound it", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An entry that starts after the deadline has passed must fail closed. It used
|
||||||
|
// to derive a negative client timeout, which [http.Client] reads as no
|
||||||
|
// deadline at all, leaving the download unbounded.
|
||||||
|
func TestNewContext_DownloadFromExpiredBudgetFailsClosed(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
<-r.Context().Done()
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
// Two entries, serialized by the concurrency limit, so the second one
|
||||||
|
// starts once the first has burned the whole budget.
|
||||||
|
payload, err := json.Marshal([]downloadFrom{
|
||||||
|
{Url: server.URL + "/first"},
|
||||||
|
{Url: server.URL + "/second"},
|
||||||
|
})
|
||||||
|
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))
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, cancel, err := newContext(echoCtx, logger, fs, 400*time.Millisecond, 0, downloadFromConfig{
|
||||||
|
maxRetry: 0,
|
||||||
|
maxConcurrency: 1,
|
||||||
|
})
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
done <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected newContext to fail against a stalling origin")
|
||||||
|
}
|
||||||
|
case <-time.After(15 * time.Second):
|
||||||
|
t.Fatal("newContext never returned: an entry starting past the deadline built an unbounded client")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeDownloadFrom(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
raw string
|
||||||
|
maxEntries int
|
||||||
|
expectErr error
|
||||||
|
expectLen int
|
||||||
|
}{
|
||||||
|
{"empty array", `[]`, 10, nil, 0},
|
||||||
|
{"under the limit", `[{"url":"http://a"},{"url":"http://b"}]`, 10, nil, 2},
|
||||||
|
{"exactly the limit", `[{"url":"http://a"},{"url":"http://b"}]`, 2, nil, 2},
|
||||||
|
{"over the limit", `[{"url":"http://a"},{"url":"http://b"}]`, 1, errTooManyDownloadFromEntries, 0},
|
||||||
|
{"no limit", `[{"url":"http://a"},{"url":"http://b"}]`, 0, nil, 2},
|
||||||
|
{"not an array", `{"url":"http://a"}`, 10, nil, 0},
|
||||||
|
{"malformed", `[{"url":`, 10, nil, 0},
|
||||||
|
{"not json", `nope`, 10, nil, 0},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
dls, err := decodeDownloadFrom(tc.raw, tc.maxEntries)
|
||||||
|
|
||||||
|
if tc.expectErr != nil {
|
||||||
|
if !errors.Is(err, tc.expectErr) {
|
||||||
|
t.Fatalf("error = %v, want %v", err, tc.expectErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tc.scenario == "not an array" || tc.scenario == "malformed" || tc.scenario == "not json" {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected an error for %q", tc.raw)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(dls) != tc.expectLen {
|
||||||
|
t.Fatalf("decoded %d entries, want %d", len(dls), tc.expectLen)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A compact array costs three bytes per entry on the wire and expands by
|
||||||
|
// roughly seventy times once unmarshalled. Decoding must stop at the limit
|
||||||
|
// rather than materialize the whole array and count afterwards.
|
||||||
|
func TestDecodeDownloadFrom_StopsBeforeMaterializingTheArray(t *testing.T) {
|
||||||
|
const entries = 2_000_000
|
||||||
|
|
||||||
|
raw := "[" + strings.Repeat("{},", entries) + "{}]"
|
||||||
|
|
||||||
|
var before, after runtime.MemStats
|
||||||
|
runtime.GC()
|
||||||
|
runtime.ReadMemStats(&before)
|
||||||
|
|
||||||
|
_, err := decodeDownloadFrom(raw, 1000)
|
||||||
|
|
||||||
|
runtime.ReadMemStats(&after)
|
||||||
|
|
||||||
|
if !errors.Is(err, errTooManyDownloadFromEntries) {
|
||||||
|
t.Fatalf("error = %v, want errTooManyDownloadFromEntries", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// json.Unmarshal on the same input allocates hundreds of MiB. Bounded
|
||||||
|
// decoding should stay in the low single-digit MiB, so this threshold is
|
||||||
|
// deliberately loose and still fails loudly on a regression.
|
||||||
|
allocated := after.TotalAlloc - before.TotalAlloc
|
||||||
|
if allocated > 32<<20 {
|
||||||
|
t.Fatalf("decoding allocated %d MiB for a %d-entry array, want the limit to bound it", allocated>>20, entries)
|
||||||
|
}
|
||||||
|
t.Logf("allocated %d KiB decoding a %d-entry array with a limit of 1000", allocated>>10, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An asynchronous conversion outlives the [echo.Context]. Echo returns that
|
||||||
|
// context to a sync.Pool as soon as the handler returns, and
|
||||||
|
// outputFilenameMiddleware runs in srv.Pre on every request, including
|
||||||
|
// /health, so a later request overwrites the store. Reading the output
|
||||||
|
// filename from it after the fact returned another caller's value.
|
||||||
|
func TestContext_OutputFilename_SurvivesEchoContextRecycling(t *testing.T) {
|
||||||
|
body := new(bytes.Buffer)
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
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())
|
||||||
|
// What outputFilenameMiddleware does for this request.
|
||||||
|
echoCtx.Set("outputFilename", "victim")
|
||||||
|
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
||||||
|
|
||||||
|
ctx, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromConfig{disable: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newContext returned error: %v", err)
|
||||||
|
}
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Echo recycles the context and another request claims the store.
|
||||||
|
echoCtx.Set("outputFilename", "attacker-controlled")
|
||||||
|
|
||||||
|
if got := ctx.OutputFilename("/tmp/out.pdf"); got != "victim.pdf" {
|
||||||
|
t.Fatalf("OutputFilename = %q, want %q", got, "victim.pdf")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A recycled context has a nil store, so the previous unguarded type assertion
|
||||||
|
// could panic. The snapshot must tolerate an absent value.
|
||||||
|
func TestContext_OutputFilename_NoHeader(t *testing.T) {
|
||||||
|
body := new(bytes.Buffer)
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
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())
|
||||||
|
|
||||||
|
// No Set call at all: the store holds nothing for "outputFilename".
|
||||||
|
echoCtx := echo.New().NewContext(req, httptest.NewRecorder())
|
||||||
|
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
||||||
|
|
||||||
|
ctx, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromConfig{disable: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newContext returned error: %v", err)
|
||||||
|
}
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if got := ctx.OutputFilename("/tmp/out.pdf"); got != "out.pdf" {
|
||||||
|
t.Fatalf("OutputFilename = %q, want the original filename %q", got, "out.pdf")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSafeExt(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
filename string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"ordinary extension", "report.pdf", ".pdf"},
|
||||||
|
{"no extension", "report", ""},
|
||||||
|
{"at the limit", "a." + strings.Repeat("x", maxDiskExtLength-1), "." + strings.Repeat("x", maxDiskExtLength-1)},
|
||||||
|
{"over the limit is dropped", "a." + strings.Repeat("x", 300), ""},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
got := safeExt(tc.filename)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("safeExt(%q) = %q, want %q", tc.filename, got, tc.want)
|
||||||
|
}
|
||||||
|
// A UUID stem is 36 characters. The whole disk name must stay
|
||||||
|
// under NAME_MAX.
|
||||||
|
if len(got)+36 > 255 {
|
||||||
|
t.Fatalf("disk name would be %d characters, over NAME_MAX", len(got)+36)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An upload whose extension exceeds NAME_MAX used to fail os.Create and return
|
||||||
|
// a bare 500. The extension is bounded, and the original name survives in
|
||||||
|
// diskToOriginal.
|
||||||
|
func TestNewContext_LongExtensionIsAccepted(t *testing.T) {
|
||||||
|
filename := "invoice." + strings.Repeat("x", 300)
|
||||||
|
|
||||||
|
body := new(bytes.Buffer)
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
part, err := writer.CreateFormFile("files", filename)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create multipart file: %v", err)
|
||||||
|
}
|
||||||
|
_, err = part.Write([]byte("%PDF-1.4"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write multipart file: %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))
|
||||||
|
|
||||||
|
ctx, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromConfig{disable: true})
|
||||||
|
if cancel != nil {
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newContext returned error for a long extension: %v", err)
|
||||||
|
}
|
||||||
|
if got := len(ctx.files); got != 1 {
|
||||||
|
t.Fatalf("files = %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A filename that cannot become a symlink (too long, "..", "/") must not fail
|
||||||
|
// the request. The symlink loop is best-effort, but its error escaped through
|
||||||
|
// the shared err variable, and ctx.files iterates randomly, so byte-identical
|
||||||
|
// requests gave different HTTP outcomes.
|
||||||
|
func TestNewContext_UnsymlinkableFilenameStillSucceeds(t *testing.T) {
|
||||||
|
for _, filename := range []string{
|
||||||
|
strings.Repeat("a", 300) + ".txt",
|
||||||
|
"..",
|
||||||
|
"/",
|
||||||
|
} {
|
||||||
|
t.Run(filename[:min(len(filename), 12)], func(t *testing.T) {
|
||||||
|
body := new(bytes.Buffer)
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
part, err := writer.CreateFormFile("files", filename)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create multipart file: %v", err)
|
||||||
|
}
|
||||||
|
_, err = part.Write([]byte("%PDF-1.4"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write multipart file: %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))
|
||||||
|
|
||||||
|
_, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromConfig{disable: true})
|
||||||
|
if cancel != nil {
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newContext failed on a best-effort symlink for %q: %v", filename, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two uploads sharing a filename must both reach the conversion. The second
|
||||||
|
// used to overwrite the first in ctx.files, so one file was silently dropped
|
||||||
|
// while both stayed on disk and counted against the body limit.
|
||||||
|
func TestNewContext_DuplicateFilenamesAreBothKept(t *testing.T) {
|
||||||
|
body := new(bytes.Buffer)
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
for _, content := range []string{"FIRST", "SECOND"} {
|
||||||
|
part, err := writer.CreateFormFile("files", "doc.pdf")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create multipart file: %v", err)
|
||||||
|
}
|
||||||
|
_, err = part.Write([]byte(content))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write multipart file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := writer.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close multipart writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/forms/pdfengines/merge", 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))
|
||||||
|
|
||||||
|
ctx, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromConfig{disable: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newContext returned error: %v", err)
|
||||||
|
}
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if got := len(ctx.files); got != 2 {
|
||||||
|
t.Fatalf("ctx.files = %d entries, want 2: a duplicate filename dropped a file", got)
|
||||||
|
}
|
||||||
|
if got := len(ctx.filesByField["files"]); got != 2 {
|
||||||
|
t.Fatalf("filesByField[files] = %d entries, want 2", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two maps must agree, and both files must be distinct on disk.
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
for _, path := range ctx.files {
|
||||||
|
if _, ok := ctx.diskToOriginal[path]; !ok {
|
||||||
|
t.Fatalf("path %q has no diskToOriginal entry", path)
|
||||||
|
}
|
||||||
|
seen[path] = struct{}{}
|
||||||
|
}
|
||||||
|
if len(seen) != 2 {
|
||||||
|
t.Fatalf("distinct disk paths = %d, want 2", len(seen))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A redirect target is filtered inside the HTTP client, so the policy verdict
|
||||||
|
// surfaces from client.Do rather than from the pre-flight check. It used to be
|
||||||
|
// interpolated into the response body, so a redirect described the allow-list,
|
||||||
|
// the deny-list or the IP policy where the first hop returns a generic 403.
|
||||||
|
func TestNewContext_DownloadFromRedirectVerdictStaysGeneric(t *testing.T) {
|
||||||
|
private := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="secret.txt"`)
|
||||||
|
_, _ = w.Write([]byte("internal"))
|
||||||
|
}))
|
||||||
|
defer private.Close()
|
||||||
|
|
||||||
|
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, private.URL+"/secret", http.StatusFound)
|
||||||
|
}))
|
||||||
|
defer redirector.Close()
|
||||||
|
|
||||||
|
payload, err := json.Marshal([]downloadFrom{{Url: redirector.URL + "/start"}})
|
||||||
|
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))
|
||||||
|
|
||||||
|
// The first hop is allowed, the redirect target is denied by the deny-list.
|
||||||
|
denyList := []*regexp2.Regexp{regexp2.MustCompile("^"+regexp.QuoteMeta(private.URL), 0)}
|
||||||
|
|
||||||
|
_, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromConfig{
|
||||||
|
denyList: denyList,
|
||||||
|
maxRetry: 0,
|
||||||
|
})
|
||||||
|
if cancel != nil {
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected the redirect to a denied host to fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
status, message := ParseError(err)
|
||||||
|
if status != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want %d: a filtered redirect must answer like a filtered first hop", status, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
if message != http.StatusText(http.StatusForbidden) {
|
||||||
|
t.Fatalf("message = %q, want the generic %q", message, http.StatusText(http.StatusForbidden))
|
||||||
|
}
|
||||||
|
// The response must not name the policy, the pattern, or the blocked host.
|
||||||
|
for _, leak := range []string{"denied list", "allowed list", "non-public", "expression", private.URL} {
|
||||||
|
if strings.Contains(message, leak) {
|
||||||
|
t.Fatalf("response message %q leaks %q", message, leak)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -37,6 +41,8 @@ type FormData struct {
|
|||||||
files map[string]string
|
files map[string]string
|
||||||
filesByField map[string][]string
|
filesByField map[string][]string
|
||||||
diskToOriginal map[string]string
|
diskToOriginal map[string]string
|
||||||
|
fileOrder map[string]int
|
||||||
|
fileBase map[string]string
|
||||||
errors error
|
errors error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,13 +481,73 @@ func (form *FormData) Stamp(target *string) *FormData {
|
|||||||
return form
|
return form
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stamps binds the absolute paths of every file uploaded with the "stamp"
|
||||||
|
// field name, in submission order. Unlike [FormData.Stamp], it keeps all of
|
||||||
|
// them so a route can apply several stamps in a single request.
|
||||||
|
func (form *FormData) Stamps(target *[]string) *FormData {
|
||||||
|
if form.errors != nil {
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
if paths, ok := form.filesByField[StampFormField]; ok {
|
||||||
|
*target = paths
|
||||||
|
}
|
||||||
|
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watermarks binds the absolute paths of every file uploaded with the
|
||||||
|
// "watermark" field name, in submission order. Unlike [FormData.Watermark], it
|
||||||
|
// keeps all of them so a route can apply several watermarks in a single request.
|
||||||
|
func (form *FormData) Watermarks(target *[]string) *FormData {
|
||||||
|
if form.errors != nil {
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
if paths, ok := form.filesByField[WatermarkFormField]; ok {
|
||||||
|
*target = paths
|
||||||
|
}
|
||||||
|
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strings binds every value submitted for key, in submission order. A field
|
||||||
|
// repeated in the multipart body (e.g. multiple "stampSource") contributes one
|
||||||
|
// entry per occurrence, which lets a route read parallel field arrays.
|
||||||
|
func (form *FormData) Strings(key string, target *[]string) *FormData {
|
||||||
|
if form.errors != nil {
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
if values, ok := form.values[key]; ok {
|
||||||
|
*target = values
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +571,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 {
|
||||||
@@ -514,24 +584,42 @@ func (form *FormData) paths(extensions []string, target *[]string) *FormData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/139.
|
// See https://github.com/gotenberg/gotenberg/issues/139.
|
||||||
originals := make(gotenberg.AlphanumericSort, len(entries))
|
//
|
||||||
for i, e := range entries {
|
// Sort on the filename as received rather than on the map key. The key
|
||||||
originals[i] = e.original
|
// carries the suffix uniqueFilename adds when two uploads share a name,
|
||||||
}
|
// and that suffix would otherwise decide the order: "doc (2).pdf" sorts
|
||||||
sort.Sort(originals)
|
// before "doc.pdf". Ordering on the received name keeps the pair adjacent,
|
||||||
|
// and the arrival index breaks the tie, so duplicates merge in the order
|
||||||
|
// the caller sent them. A file with a unique name is unaffected, since its
|
||||||
|
// received name and its key are the same string.
|
||||||
|
sort.SliceStable(entries, func(i, j int) bool {
|
||||||
|
nameI := form.receivedName(entries[i].disk, entries[i].original)
|
||||||
|
nameJ := form.receivedName(entries[j].disk, entries[j].original)
|
||||||
|
if nameI != nameJ {
|
||||||
|
return gotenberg.AlphanumericSort{nameI, nameJ}.Less(0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return form.fileOrder[entries[i].disk] < form.fileOrder[entries[j].disk]
|
||||||
|
})
|
||||||
|
|
||||||
// Build a lookup from original name to disk path.
|
|
||||||
lookup := make(map[string]string, len(entries))
|
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
lookup[e.original] = e.disk
|
*target = append(*target, e.disk)
|
||||||
}
|
|
||||||
for _, o := range originals {
|
|
||||||
*target = append(*target, lookup[o])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return form
|
return form
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// receivedName returns the filename the file at disk arrived under, before
|
||||||
|
// de-duplication, falling back to fallback.
|
||||||
|
func (form *FormData) receivedName(disk, fallback string) string {
|
||||||
|
base, ok := form.fileBase[disk]
|
||||||
|
if ok {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
// append adds an error to the list of errors.
|
// append adds an error to the list of errors.
|
||||||
func (form *FormData) append(err error) {
|
func (form *FormData) append(err error) {
|
||||||
form.errors = errors.Join(form.errors, err)
|
form.errors = errors.Join(form.errors, err)
|
||||||
@@ -544,7 +632,7 @@ func (form *FormData) mustValue(key string, target any, defaultValue any) *FormD
|
|||||||
val, ok := form.values[key]
|
val, ok := form.values[key]
|
||||||
|
|
||||||
if !ok || val[0] == "" {
|
if !ok || val[0] == "" {
|
||||||
switch t := (target).(type) {
|
switch t := target.(type) {
|
||||||
case *string:
|
case *string:
|
||||||
*t = defaultValue.(string)
|
*t = defaultValue.(string)
|
||||||
case *bool:
|
case *bool:
|
||||||
@@ -591,7 +679,7 @@ func (form *FormData) mustMandatoryField(key string, target any) *FormData {
|
|||||||
func (form *FormData) mustAssign(key, value string, target any) *FormData {
|
func (form *FormData) mustAssign(key, value string, target any) *FormData {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
switch t := (target).(type) {
|
switch t := target.(type) {
|
||||||
case *string:
|
case *string:
|
||||||
*t = value
|
*t = value
|
||||||
case *bool:
|
case *bool:
|
||||||
|
|||||||
@@ -1783,3 +1783,193 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormData_Strings(t *testing.T) {
|
||||||
|
form := &FormData{
|
||||||
|
values: map[string][]string{
|
||||||
|
"foo": {"a", "b", "c"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var got []string
|
||||||
|
form.Strings("foo", &got)
|
||||||
|
|
||||||
|
if want := []string{"a", "b", "c"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("expected %+v, got %+v", want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
var missing []string
|
||||||
|
form.Strings("bar", &missing)
|
||||||
|
if missing != nil {
|
||||||
|
t.Errorf("expected nil for a missing key, got %+v", missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormData_Stamps(t *testing.T) {
|
||||||
|
form := &FormData{
|
||||||
|
filesByField: map[string][]string{
|
||||||
|
StampFormField: {"/tmp/abc/a.png", "/tmp/abc/b.pdf"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var got []string
|
||||||
|
form.Stamps(&got)
|
||||||
|
|
||||||
|
if want := []string{"/tmp/abc/a.png", "/tmp/abc/b.pdf"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("expected %+v, got %+v", want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
empty := &FormData{}
|
||||||
|
var none []string
|
||||||
|
empty.Stamps(&none)
|
||||||
|
if none != nil {
|
||||||
|
t.Errorf("expected nil when no stamp file was uploaded, got %+v", none)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormData_Watermarks(t *testing.T) {
|
||||||
|
form := &FormData{
|
||||||
|
filesByField: map[string][]string{
|
||||||
|
WatermarkFormField: {"/tmp/abc/a.png"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var got []string
|
||||||
|
form.Watermarks(&got)
|
||||||
|
|
||||||
|
if want := []string{"/tmp/abc/a.png"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("expected %+v, got %+v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// De-duplicating a repeated filename must not change merge order. Files with
|
||||||
|
// unique names keep exactly the order they had before de-duplication existed,
|
||||||
|
// and two files sharing a name merge in the order the caller sent them.
|
||||||
|
func TestFormData_paths_DuplicateFilenamesKeepUploadOrder(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
files map[string]string
|
||||||
|
fileBase map[string]string
|
||||||
|
order map[string]int
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "unique names sort exactly as before",
|
||||||
|
files: map[string]string{"b.pdf": "/w/2", "a.pdf": "/w/1", "c.pdf": "/w/3"},
|
||||||
|
fileBase: map[string]string{"/w/1": "a.pdf", "/w/2": "b.pdf", "/w/3": "c.pdf"},
|
||||||
|
order: map[string]int{"/w/1": 0, "/w/2": 1, "/w/3": 2},
|
||||||
|
want: []string{"/w/1", "/w/2", "/w/3"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "numeric prefixes still win",
|
||||||
|
files: map[string]string{"10_x.pdf": "/w/3", "2_x.pdf": "/w/2", "1_x.pdf": "/w/1"},
|
||||||
|
fileBase: map[string]string{"/w/1": "1_x.pdf", "/w/2": "2_x.pdf", "/w/3": "10_x.pdf"},
|
||||||
|
order: map[string]int{"/w/1": 0, "/w/2": 1, "/w/3": 2},
|
||||||
|
want: []string{"/w/1", "/w/2", "/w/3"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "duplicates merge in upload order, not suffix order",
|
||||||
|
files: map[string]string{"doc.pdf": "/w/1", "doc (2).pdf": "/w/2"},
|
||||||
|
fileBase: map[string]string{"/w/1": "doc.pdf", "/w/2": "doc.pdf"},
|
||||||
|
order: map[string]int{"/w/1": 0, "/w/2": 1},
|
||||||
|
want: []string{"/w/1", "/w/2"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "duplicates stay adjacent and in position",
|
||||||
|
files: map[string]string{
|
||||||
|
"a.pdf": "/w/1", "doc.pdf": "/w/2", "doc (2).pdf": "/w/3", "z.pdf": "/w/4",
|
||||||
|
},
|
||||||
|
fileBase: map[string]string{
|
||||||
|
"/w/1": "a.pdf", "/w/2": "doc.pdf", "/w/3": "doc.pdf", "/w/4": "z.pdf",
|
||||||
|
},
|
||||||
|
order: map[string]int{"/w/1": 0, "/w/2": 1, "/w/3": 2, "/w/4": 3},
|
||||||
|
want: []string{"/w/1", "/w/2", "/w/3", "/w/4"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "three copies keep their order",
|
||||||
|
files: map[string]string{"r.pdf": "/w/1", "r (2).pdf": "/w/2", "r (3).pdf": "/w/3"},
|
||||||
|
fileBase: map[string]string{"/w/1": "r.pdf", "/w/2": "r.pdf", "/w/3": "r.pdf"},
|
||||||
|
order: map[string]int{"/w/1": 0, "/w/2": 1, "/w/3": 2},
|
||||||
|
want: []string{"/w/1", "/w/2", "/w/3"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
form := &FormData{
|
||||||
|
files: tc.files,
|
||||||
|
filesByField: map[string][]string{},
|
||||||
|
fileBase: tc.fileBase,
|
||||||
|
fileOrder: tc.order,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map iteration is randomised, so run it repeatedly: an unstable
|
||||||
|
// comparator shows up as a differing result across runs.
|
||||||
|
for range 50 {
|
||||||
|
var got []string
|
||||||
|
form.paths([]string{".pdf"}, &got)
|
||||||
|
|
||||||
|
if len(got) != len(tc.want) {
|
||||||
|
t.Fatalf("paths() returned %d entries, want %d", len(got), len(tc.want))
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tc.want[i] {
|
||||||
|
t.Fatalf("paths() = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/labstack/echo/v4/middleware"
|
"github.com/labstack/echo/v4/middleware"
|
||||||
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
"go.opentelemetry.io/otel"
|
"go.opentelemetry.io/otel"
|
||||||
"go.opentelemetry.io/otel/attribute"
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.opentelemetry.io/otel/propagation"
|
"go.opentelemetry.io/otel/propagation"
|
||||||
@@ -43,7 +44,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 +52,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,13 +88,37 @@ func ParseError(err error) (int, string) {
|
|||||||
return http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)
|
return http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// statusClientClosedRequest is the non-standard 499 status (nginx convention)
|
||||||
|
// recorded when the client aborts the request before it completes. It keeps
|
||||||
|
// such outcomes out of the 5xx range in the access log and Prometheus metrics.
|
||||||
|
const statusClientClosedRequest = 499
|
||||||
|
|
||||||
|
// requestCanceled reports whether err is the result of the client aborting the
|
||||||
|
// request rather than a server-side failure. It requires both that err wraps
|
||||||
|
// [context.Canceled] and that the request context itself was canceled, so a
|
||||||
|
// context.Canceled originating elsewhere still surfaces as an internal error.
|
||||||
|
// A server-side timeout is [context.DeadlineExceeded], mapped to 503 by
|
||||||
|
// [ParseError], and is deliberately not treated as a client abort.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1627.
|
||||||
|
func requestCanceled(c echo.Context, err error) bool {
|
||||||
|
return errors.Is(err, context.Canceled) && errors.Is(c.Request().Context().Err(), context.Canceled)
|
||||||
|
}
|
||||||
|
|
||||||
// httpErrorHandler is the centralized HTTP error handler. It parses the error,
|
// httpErrorHandler is the centralized HTTP error handler. It parses the error,
|
||||||
// returns a response as "text/plain; charset=UTF-8".
|
// returns a response as "text/plain; charset=UTF-8".
|
||||||
func httpErrorHandler() echo.HTTPErrorHandler {
|
func httpErrorHandler() echo.HTTPErrorHandler {
|
||||||
return func(err error, c echo.Context) {
|
return func(err error, c echo.Context) {
|
||||||
logger := c.Get("logger").(*slog.Logger)
|
logger := c.Get("logger").(*slog.Logger)
|
||||||
status, message := ParseError(err)
|
|
||||||
|
|
||||||
|
if requestCanceled(c, err) {
|
||||||
|
// The client is gone, so writing a body would only fail and add
|
||||||
|
// noise. Record the status so the access log and metrics classify
|
||||||
|
// it as a client abort rather than an internal error.
|
||||||
|
c.Response().WriteHeader(statusClientClosedRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status, message := ParseError(err)
|
||||||
c.Response().Header().Add(echo.HeaderContentType, echo.MIMETextPlainCharsetUTF8)
|
c.Response().Header().Add(echo.HeaderContentType, echo.MIMETextPlainCharsetUTF8)
|
||||||
|
|
||||||
err = c.String(status, message)
|
err = c.String(status, message)
|
||||||
@@ -150,9 +175,16 @@ func outputFilenameMiddleware() echo.MiddlewareFunc {
|
|||||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
filename := c.Request().Header.Get("Gotenberg-Output-Filename")
|
filename := c.Request().Header.Get("Gotenberg-Output-Filename")
|
||||||
|
// Keep only the last path segment, so that a caller cannot name an
|
||||||
|
// output file after a path.
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/1227.
|
// See https://github.com/gotenberg/gotenberg/issues/1227.
|
||||||
|
//
|
||||||
|
// [filepath.Base] alone is not enough: on Linux it does not treat a
|
||||||
|
// backslash as a separator, and this value reaches archive entry
|
||||||
|
// names. Use the same sanitizer as the other caller-supplied
|
||||||
|
// filenames.
|
||||||
if filename != "" {
|
if filename != "" {
|
||||||
filename = filepath.Base(filename)
|
filename = sanitizeFilename(filename)
|
||||||
}
|
}
|
||||||
c.Set("outputFilename", filename)
|
c.Set("outputFilename", filename)
|
||||||
// Call the next middleware in the chain.
|
// Call the next middleware in the chain.
|
||||||
@@ -257,9 +289,15 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
|
|||||||
finishTime := time.Now()
|
finishTime := time.Now()
|
||||||
|
|
||||||
status := c.Response().Status
|
status := c.Response().Status
|
||||||
|
canceled := false
|
||||||
if err != nil {
|
if err != nil {
|
||||||
parsedStatus, _ := ParseError(err)
|
canceled = requestCanceled(c, err)
|
||||||
status = parsedStatus
|
if canceled {
|
||||||
|
status = statusClientClosedRequest
|
||||||
|
} else {
|
||||||
|
parsedStatus, _ := ParseError(err)
|
||||||
|
status = parsedStatus
|
||||||
|
}
|
||||||
|
|
||||||
span.SetAttributes(attribute.String("error", err.Error()))
|
span.SetAttributes(attribute.String("error", err.Error()))
|
||||||
c.Error(err)
|
c.Error(err)
|
||||||
@@ -271,44 +309,59 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
|
|||||||
WriteBytes: c.Response().Size,
|
WriteBytes: c.Response().Size,
|
||||||
})...)
|
})...)
|
||||||
|
|
||||||
accessLogger := logger.
|
// Pick the level and message before building the record: err.Error
|
||||||
With(slog.String("log_type", "access")).
|
// walks a joined error chain, and the nil-error branch has no use
|
||||||
With(slog.String("correlation_id", correlationId)).
|
// for it.
|
||||||
With(slog.String("remote_ip", c.RealIP())).
|
level := slog.LevelInfo
|
||||||
With(slog.String("host", c.Request().Host)).
|
msg := "request handled"
|
||||||
With(slog.String("uri", c.Request().RequestURI)).
|
|
||||||
With(slog.String("method", c.Request().Method)).
|
|
||||||
With(slog.String("path", routePath)).
|
|
||||||
With(slog.String("referer", c.Request().Referer())).
|
|
||||||
With(slog.String("user_agent", c.Request().UserAgent())).
|
|
||||||
With(slog.Int("status", c.Response().Status)).
|
|
||||||
With(slog.Int64("latency", int64(finishTime.Sub(startTime)))).
|
|
||||||
With(slog.String("latency_human", finishTime.Sub(startTime).String())).
|
|
||||||
With(slog.Int64("bytes_in", c.Request().ContentLength)).
|
|
||||||
With(slog.Int64("bytes_out", c.Response().Size))
|
|
||||||
|
|
||||||
if err != nil {
|
switch {
|
||||||
accessLogger.ErrorContext(ctx, err.Error())
|
case err == nil:
|
||||||
} else {
|
case canceled:
|
||||||
accessLogger.InfoContext(ctx, "request handled")
|
// A client abort is expected, not a server failure; keep it
|
||||||
|
// visible but out of the error stream.
|
||||||
|
msg = err.Error()
|
||||||
|
default:
|
||||||
|
level = slog.LevelError
|
||||||
|
msg = err.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One record rather than a chain of With calls. Each With clones
|
||||||
|
// the whole handler chain, and this logger fans out to a JSON
|
||||||
|
// handler and an OpenTelemetry bridge that is wired in even when no
|
||||||
|
// exporter is configured, so a 14-deep chain clones both sub-chains
|
||||||
|
// 14 times to emit a single line.
|
||||||
|
latency := finishTime.Sub(startTime)
|
||||||
|
|
||||||
|
logger.LogAttrs(ctx, level, msg,
|
||||||
|
slog.String("log_type", "access"),
|
||||||
|
slog.String("correlation_id", correlationId),
|
||||||
|
slog.String("remote_ip", c.RealIP()),
|
||||||
|
slog.String("host", c.Request().Host),
|
||||||
|
slog.String("uri", c.Request().RequestURI),
|
||||||
|
slog.String("method", c.Request().Method),
|
||||||
|
slog.String("path", routePath),
|
||||||
|
slog.String("referer", c.Request().Referer()),
|
||||||
|
slog.String("user_agent", c.Request().UserAgent()),
|
||||||
|
slog.Int("status", c.Response().Status),
|
||||||
|
slog.Int64("latency", int64(latency)),
|
||||||
|
slog.String("latency_human", latency.String()),
|
||||||
|
slog.Int64("bytes_in", c.Request().ContentLength),
|
||||||
|
slog.Int64("bytes_out", c.Response().Size),
|
||||||
|
)
|
||||||
|
|
||||||
additionalAttributes := []attribute.KeyValue{
|
additionalAttributes := []attribute.KeyValue{
|
||||||
semconvSrv.Route(routePath),
|
semconvSrv.Route(routePath),
|
||||||
}
|
}
|
||||||
|
|
||||||
semconvSrv.RecordMetrics(ctx, semconvutil.ServerMetricData{
|
semconvSrv.RecordMetrics(ctx, semconvutil.ServerMetricData{
|
||||||
ServerName: serverName,
|
ServerName: serverName,
|
||||||
ResponseSize: c.Response().Size,
|
ResponseSize: c.Response().Size,
|
||||||
MetricAttributes: semconvutil.MetricAttributes{
|
Req: request,
|
||||||
Req: request,
|
StatusCode: status,
|
||||||
StatusCode: status,
|
AdditionalAttributes: additionalAttributes,
|
||||||
AdditionalAttributes: additionalAttributes,
|
RequestSize: request.ContentLength,
|
||||||
},
|
ElapsedTime: float64(time.Since(startTime)) / float64(time.Millisecond),
|
||||||
MetricData: semconvutil.MetricData{
|
|
||||||
RequestSize: request.ContentLength,
|
|
||||||
ElapsedTime: float64(time.Since(startTime)) / float64(time.Millisecond),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -327,6 +380,63 @@ func basicAuthMiddleware(username, password string) echo.MiddlewareFunc {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildOidcVerifier constructs an OIDC ID token verifier. When oidcJwksUrl is
|
||||||
|
// set, the keys are fetched from that URL lazily, so there is no network call at
|
||||||
|
// startup; otherwise the provider is discovered from its issuer, which does one.
|
||||||
|
// Both paths use an OTEL-instrumented HTTP client, so the JWKS and discovery
|
||||||
|
// fetches produce client spans.
|
||||||
|
func (a *Api) buildOidcVerifier() (*oidc.IDTokenVerifier, error) {
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
Transport: otelhttp.NewTransport(http.DefaultTransport),
|
||||||
|
}
|
||||||
|
ctx := oidc.ClientContext(context.Background(), httpClient)
|
||||||
|
|
||||||
|
cfg := &oidc.Config{
|
||||||
|
ClientID: a.oidcAudience,
|
||||||
|
SupportedSigningAlgs: []string{oidc.RS256, oidc.ES256},
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.oidcJwksUrl != "" {
|
||||||
|
keySet := oidc.NewRemoteKeySet(ctx, a.oidcJwksUrl)
|
||||||
|
return oidc.NewVerifier(a.oidcIssuer, keySet, cfg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, err := oidc.NewProvider(ctx, a.oidcIssuer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("discover OIDC provider '%s': %w", a.oidcIssuer, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider.Verifier(cfg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// oidcAuthMiddleware validates the Bearer token in the Authorization header with
|
||||||
|
// the OIDC verifier, which checks the signature against the provider's rotating
|
||||||
|
// JWKS and the issuer, audience and expiry claims. It answers 401 for a missing
|
||||||
|
// or invalid token, logging the underlying reason at debug level without leaking
|
||||||
|
// it to the client.
|
||||||
|
func oidcAuthMiddleware(verifier *oidc.IDTokenVerifier) echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
rawToken, ok := strings.CutPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
|
||||||
|
if !ok || rawToken == "" {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "a Bearer token is required in the Authorization header")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := verifier.Verify(c.Request().Context(), rawToken)
|
||||||
|
if err != nil {
|
||||||
|
if logger, ok := c.Get("logger").(*slog.Logger); ok && logger != nil {
|
||||||
|
logger.DebugContext(c.Request().Context(), "OIDC token verification failed", slog.Any("error", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "the Bearer token is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// contextMiddleware, middleware for "multipart/form-data" requests, sets the
|
// contextMiddleware, middleware for "multipart/form-data" requests, sets the
|
||||||
// [Context] and related context.CancelFunc in the [echo.Context] under
|
// [Context] and related context.CancelFunc in the [echo.Context] under
|
||||||
// "context" and "cancel". If the process is synchronous, it also handles the
|
// "context" and "cancel". If the process is synchronous, it also handles the
|
||||||
|
|||||||
@@ -1,15 +1,134 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc/oidctest"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestRequestCanceled pins the client-abort discriminator: only a
|
||||||
|
// context.Canceled that stems from the request context counts, so a server
|
||||||
|
// timeout or an unrelated cancellation still surfaces as an internal failure.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1627.
|
||||||
|
func TestRequestCanceled(t *testing.T) {
|
||||||
|
canceled, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
timedOut, cancelTimeout := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
|
||||||
|
defer cancelTimeout()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
reqCtx context.Context
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"client abort", canceled, context.Canceled, true},
|
||||||
|
{"wrapped client abort", canceled, fmt.Errorf("convert to PDF: %w", context.Canceled), true},
|
||||||
|
{"canceled error but live request", context.Background(), context.Canceled, false},
|
||||||
|
{"canceled request but unrelated error", canceled, errors.New("boom"), false},
|
||||||
|
{"server timeout is not a client abort", timedOut, context.DeadlineExceeded, false},
|
||||||
|
{"no error", canceled, nil, false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(tc.reqCtx)
|
||||||
|
c := echo.New().NewContext(req, httptest.NewRecorder())
|
||||||
|
if got := requestCanceled(c, tc.err); got != tc.want {
|
||||||
|
t.Fatalf("requestCanceled = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHttpErrorHandler_ClientClosedRequest ensures a client abort is recorded
|
||||||
|
// as 499 rather than 500, and that a genuine failure keeps its status.
|
||||||
|
func TestHttpErrorHandler_ClientClosedRequest(t *testing.T) {
|
||||||
|
canceled, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
reqCtx context.Context
|
||||||
|
err error
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{"client abort", canceled, fmt.Errorf("convert to PDF: %w", context.Canceled), statusClientClosedRequest},
|
||||||
|
{"internal failure", context.Background(), errors.New("boom"), http.StatusInternalServerError},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(tc.reqCtx)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := echo.New().NewContext(req, rec)
|
||||||
|
c.Set("logger", slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
|
httpErrorHandler()(tc.err, c)
|
||||||
|
|
||||||
|
if rec.Code != tc.wantStatus {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOutputFilenameMiddleware pins the sanitizing of the
|
||||||
|
// "Gotenberg-Output-Filename" header. The value reaches archive entry names and
|
||||||
|
// a Content-Disposition header, so a path separator must never survive it.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1227 and
|
||||||
|
// GHSA-hwc4-gmrw-5222.
|
||||||
|
func TestOutputFilenameMiddleware(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
header string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"no header", "", ""},
|
||||||
|
{"plain filename", "foo", "foo"},
|
||||||
|
{"POSIX path", "/tmp/foo", "foo"},
|
||||||
|
{"POSIX traversal", "../../../etc/passwd", "passwd"},
|
||||||
|
{"Windows traversal", `..\..\..\..\Windows\System32\evil`, "evil"},
|
||||||
|
{"rooted Windows path", `C:\Windows\Temp\evil`, "evil"},
|
||||||
|
{"mixed separators", `a/b\c`, "c"},
|
||||||
|
{"trailing separator", "/tmp/", ""},
|
||||||
|
{"bare dot dot", "..", ".."},
|
||||||
|
{"control characters", "fo\x01o\x7f", "foo"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
handler := outputFilenameMiddleware()(func(c echo.Context) error { return nil })
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||||
|
if tc.header != "" {
|
||||||
|
req.Header.Set("Gotenberg-Output-Filename", tc.header)
|
||||||
|
}
|
||||||
|
c := echo.New().NewContext(req, httptest.NewRecorder())
|
||||||
|
|
||||||
|
err := handler(c)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := c.Get("outputFilename").(string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("outputFilename is not set as a string")
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("outputFilename = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHardTimeoutMiddleware_MissingLoggerReturnsErrorInsteadOfPanicking(t *testing.T) {
|
func TestHardTimeoutMiddleware_MissingLoggerReturnsErrorInsteadOfPanicking(t *testing.T) {
|
||||||
mw := hardTimeoutMiddleware(100 * time.Millisecond)
|
mw := hardTimeoutMiddleware(100 * time.Millisecond)
|
||||||
handler := mw(func(c echo.Context) error { return nil })
|
handler := mw(func(c echo.Context) error { return nil })
|
||||||
@@ -37,3 +156,90 @@ func TestHardTimeoutMiddleware_MissingLoggerReturnsErrorInsteadOfPanicking(t *te
|
|||||||
t.Fatalf("error = %q, want a message mentioning logger", err)
|
t.Fatalf("error = %q, want a message mentioning logger", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOidcAuthMiddleware(t *testing.T) {
|
||||||
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyID = "test-key"
|
||||||
|
audience = "gotenberg"
|
||||||
|
)
|
||||||
|
|
||||||
|
oidcServer := &oidctest.Server{
|
||||||
|
PublicKeys: []oidctest.PublicKey{
|
||||||
|
{PublicKey: privateKey.Public(), KeyID: keyID, Algorithm: oidc.RS256},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(oidcServer)
|
||||||
|
defer srv.Close()
|
||||||
|
oidcServer.SetIssuer(srv.URL)
|
||||||
|
|
||||||
|
// Building through the module's own helper exercises the discovery path too.
|
||||||
|
a := &Api{oidcIssuer: srv.URL, oidcAudience: audience}
|
||||||
|
verifier, err := a.buildOidcVerifier()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build verifier: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := func(issuer, aud string, expiresIn time.Duration) string {
|
||||||
|
now := time.Now()
|
||||||
|
return fmt.Sprintf(`{"iss":%q,"aud":%q,"sub":"user","exp":%d,"iat":%d}`,
|
||||||
|
issuer, aud, now.Add(expiresIn).Unix(), now.Unix())
|
||||||
|
}
|
||||||
|
sign := func(claims string) string {
|
||||||
|
return oidctest.SignIDToken(privateKey, keyID, oidc.RS256, claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
otherKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate other key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
authHeader string
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{"valid token", "Bearer " + sign(claims(srv.URL, audience, time.Hour)), http.StatusOK},
|
||||||
|
{"missing header", "", http.StatusUnauthorized},
|
||||||
|
{"wrong scheme", "Basic Zm9vOmJhcg==", http.StatusUnauthorized},
|
||||||
|
{"empty bearer", "Bearer ", http.StatusUnauthorized},
|
||||||
|
{"malformed token", "Bearer not-a-jwt", http.StatusUnauthorized},
|
||||||
|
{"wrong issuer", "Bearer " + sign(claims("https://evil.example/", audience, time.Hour)), http.StatusUnauthorized},
|
||||||
|
{"wrong audience", "Bearer " + sign(claims(srv.URL, "someone-else", time.Hour)), http.StatusUnauthorized},
|
||||||
|
{"expired token", "Bearer " + sign(claims(srv.URL, audience, -time.Hour)), http.StatusUnauthorized},
|
||||||
|
{"unknown signing key", "Bearer " + oidctest.SignIDToken(otherKey, "unknown", oidc.RS256, claims(srv.URL, audience, time.Hour)), http.StatusUnauthorized},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
if tc.authHeader != "" {
|
||||||
|
req.Header.Set("Authorization", tc.authHeader)
|
||||||
|
}
|
||||||
|
c := echo.New().NewContext(req, httptest.NewRecorder())
|
||||||
|
|
||||||
|
handler := oidcAuthMiddleware(verifier)(func(c echo.Context) error {
|
||||||
|
return c.NoContent(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
err := handler(c)
|
||||||
|
|
||||||
|
if tc.wantStatus == http.StatusOK {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected the request to pass, got error: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var httpErr *echo.HTTPError
|
||||||
|
if !errors.As(err, &httpErr) {
|
||||||
|
t.Fatalf("expected an *echo.HTTPError, got %T (%v)", err, err)
|
||||||
|
}
|
||||||
|
if httpErr.Code != tc.wantStatus {
|
||||||
|
t.Fatalf("status = %d, want %d", httpErr.Code, tc.wantStatus)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -23,10 +23,29 @@ import (
|
|||||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// chromiumDisableFeatures is the value of Chromium's --disable-features
|
||||||
|
// switch.
|
||||||
|
//
|
||||||
|
// It restates the "site-per-process,Translate,BlinkGenPropertyTrees" default
|
||||||
|
// from chromedp.DefaultExecAllocatorOptions (chromedp v0.14.2) on purpose:
|
||||||
|
// chromedp.Flag keys its flags by switch name, so a second --disable-features
|
||||||
|
// replaces chromedp's value instead of merging with it. Revisit this list when
|
||||||
|
// bumping chromedp.
|
||||||
|
//
|
||||||
|
// WebUIOmniboxPopup and WebUIOmniboxAimPopup became enabled by default in
|
||||||
|
// Chromium 151.0.7922.132. Their presenters build the address-bar popup WebUI
|
||||||
|
// at browser start, headless included, which leaves a renderer process holding
|
||||||
|
// ~85 MB of anonymous memory for a UI a PDF service can never show. Chromium
|
||||||
|
// silently ignores feature names it does not know, so both stay harmless on
|
||||||
|
// older builds (they exist but default to disabled on the Chromium pinned for
|
||||||
|
// ppc64el) and once upstream eventually removes them.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1656.
|
||||||
|
const chromiumDisableFeatures = "site-per-process,Translate,BlinkGenPropertyTrees,WebUIOmniboxPopup,WebUIOmniboxAimPopup"
|
||||||
|
|
||||||
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 +57,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
|
||||||
|
|
||||||
@@ -48,6 +68,7 @@ type browserArguments struct {
|
|||||||
denyPublicIPs bool
|
denyPublicIPs bool
|
||||||
clearCache bool
|
clearCache bool
|
||||||
clearCookies bool
|
clearCookies bool
|
||||||
|
clearStorage bool
|
||||||
disableJavaScript bool
|
disableJavaScript bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +79,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 +98,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 +106,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")
|
||||||
}
|
}
|
||||||
@@ -110,6 +152,8 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
|||||||
chromedp.Flag("disable-dev-shm-usage", true),
|
chromedp.Flag("disable-dev-shm-usage", true),
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/1293.
|
// See https://github.com/gotenberg/gotenberg/issues/1293.
|
||||||
chromedp.Flag("disable-component-update", false),
|
chromedp.Flag("disable-component-update", false),
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1656.
|
||||||
|
chromedp.Flag("disable-features", chromiumDisableFeatures),
|
||||||
)
|
)
|
||||||
|
|
||||||
if b.arguments.allowInsecureLocalhost {
|
if b.arguments.allowInsecureLocalhost {
|
||||||
@@ -157,6 +201,28 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
|||||||
return fmt.Errorf("start pinning proxy: %w", err)
|
return fmt.Errorf("start pinning proxy: %w", err)
|
||||||
}
|
}
|
||||||
opts = append(opts, chromedp.ProxyServer(b.pinningProxy.URL()))
|
opts = append(opts, chromedp.ProxyServer(b.pinningProxy.URL()))
|
||||||
|
|
||||||
|
if b.arguments.denyPrivateIPs || b.arguments.denyPublicIPs {
|
||||||
|
// Chromium implicitly bypasses the proxy for loopback and
|
||||||
|
// link-local destinations. A WebSocket handshake is never surfaced
|
||||||
|
// as a fetch.EventRequestPaused, so listenForEventRequestPaused
|
||||||
|
// cannot filter it; the pinning proxy is the only layer that sees
|
||||||
|
// it. Left alone, a page could open a WebSocket to 127.0.0.1, ::1,
|
||||||
|
// localhost, or the link-local cloud metadata endpoint
|
||||||
|
// (169.254.169.254) and reach it unfiltered. "<-loopback>" removes
|
||||||
|
// the implicit bypass so those handshakes also traverse the pinning
|
||||||
|
// proxy and go through [gotenberg.DecideOutbound] like every other
|
||||||
|
// request.
|
||||||
|
//
|
||||||
|
// Gated on the IP-class policy: it is the control this closes, and
|
||||||
|
// under it loopback and link-local HTTP sub-resources are already
|
||||||
|
// blocked by listenForEventRequestPaused before they would reach
|
||||||
|
// the proxy, so this adds only the missing WebSocket coverage. When
|
||||||
|
// the policy is off, loopback is not restricted, and routing it
|
||||||
|
// through the proxy would merely change how an unreachable loopback
|
||||||
|
// sub-resource reports its failure.
|
||||||
|
opts = append(opts, chromedp.Flag("proxy-bypass-list", "<-loopback>"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/524.
|
// See https://github.com/gotenberg/gotenberg/issues/524.
|
||||||
@@ -178,7 +244,7 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
|||||||
if stopErr != nil {
|
if stopErr != nil {
|
||||||
logger.ErrorContext(context.Background(), fmt.Sprintf("stop pinning proxy after failed start: %s", stopErr))
|
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; if Chromium is slow to start, raise --chromium-start-timeout (currently %s)", err, b.arguments.wsUrlReadTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
b.ctxMu.Lock()
|
b.ctxMu.Lock()
|
||||||
@@ -314,15 +380,16 @@ 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(),
|
||||||
clearCacheActionFunc(logger, b.arguments.clearCache),
|
clearCacheActionFunc(logger, b.arguments.clearCache),
|
||||||
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
||||||
|
clearStorageActionFunc(logger, b.arguments.clearStorage, url),
|
||||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||||
setCookiesActionFunc(logger, options.Cookies),
|
setCookiesActionFunc(logger, options.Cookies),
|
||||||
userAgentOverride(logger, options.UserAgent),
|
userAgentOverride(logger, options.UserAgent),
|
||||||
@@ -334,21 +401,22 @@ 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(),
|
||||||
clearCacheActionFunc(logger, b.arguments.clearCache),
|
clearCacheActionFunc(logger, b.arguments.clearCache),
|
||||||
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
||||||
|
clearStorageActionFunc(logger, b.arguments.clearStorage, url),
|
||||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||||
setCookiesActionFunc(logger, options.Cookies),
|
setCookiesActionFunc(logger, options.Cookies),
|
||||||
userAgentOverride(logger, options.UserAgent),
|
userAgentOverride(logger, options.UserAgent),
|
||||||
@@ -367,7 +435,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, u
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url string, options Options, tasks chromedp.Tasks) error {
|
func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url string, options Options, aggregate *networkAggregate, tasks chromedp.Tasks) error {
|
||||||
if !b.isStarted.Load() {
|
if !b.isStarted.Load() {
|
||||||
return errors.New("browser not started, cannot handle tasks")
|
return errors.New("browser not started, cannot handle tasks")
|
||||||
}
|
}
|
||||||
@@ -396,6 +464,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.
|
||||||
@@ -409,6 +480,17 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
|||||||
extraHttpHeaders: options.ExtraHttpHeaders,
|
extraHttpHeaders: options.ExtraHttpHeaders,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// WebSocket handshakes never surface as fetch.EventRequestPaused, so
|
||||||
|
// listenForEventRequestPaused above cannot filter them. Validate them
|
||||||
|
// against the same allow / deny lists and IP-class policy.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1011.
|
||||||
|
listenForEventWebSocketCreated(taskCtx, logger, eventWebSocketCreatedOptions{
|
||||||
|
allowList: b.arguments.allowList,
|
||||||
|
denyList: b.arguments.denyList,
|
||||||
|
denyPrivateIPs: b.arguments.denyPrivateIPs,
|
||||||
|
denyPublicIPs: b.arguments.denyPublicIPs,
|
||||||
|
})
|
||||||
|
|
||||||
var (
|
var (
|
||||||
invalidHttpStatusCode error
|
invalidHttpStatusCode error
|
||||||
invalidHttpStatusCodeMu sync.RWMutex
|
invalidHttpStatusCodeMu sync.RWMutex
|
||||||
@@ -462,8 +544,45 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
|||||||
cancelOnMainPageError: taskCancel,
|
cancelOnMainPageError: taskCancel,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
var (
|
||||||
|
crashed error
|
||||||
|
crashedMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1640.
|
||||||
|
listenForEventTargetCrashed(taskCtx, logger, eventTargetCrashedOptions{
|
||||||
|
crashed: &crashed,
|
||||||
|
crashedMu: &crashedMu,
|
||||||
|
cancel: taskCancel,
|
||||||
|
})
|
||||||
|
|
||||||
runErr := chromedp.Run(taskCtx, tasks...)
|
runErr := chromedp.Run(taskCtx, tasks...)
|
||||||
|
|
||||||
|
// A crashed renderer is the root cause of every other failure this
|
||||||
|
// conversion may have recorded, so check it first.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1640.
|
||||||
|
crashedMu.RLock()
|
||||||
|
defer crashedMu.RUnlock()
|
||||||
|
|
||||||
|
if crashed != nil {
|
||||||
|
return fmt.Errorf("handle tasks: %w", crashed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The browser context is only ever canceled when the browser process
|
||||||
|
// dies or is stopped, never on a request timeout. If the run failed
|
||||||
|
// and the browser context is done, the conversion failed because the
|
||||||
|
// browser went away mid-flight; fail fast with the same crash error
|
||||||
|
// instead of letting the error fall through as a generic context
|
||||||
|
// cancellation. The check is gated on runErr so a successful
|
||||||
|
// conversion is never discarded by a browser death that lands right
|
||||||
|
// after it.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1640.
|
||||||
|
if runErr != nil {
|
||||||
|
if err := b.ctx.Err(); err != nil {
|
||||||
|
return fmt.Errorf("handle tasks: %w", ErrChromiumCrashed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check event-driven errors first — they take priority over chromedp.Run
|
// Check event-driven errors first — they take priority over chromedp.Run
|
||||||
// errors because they carry the actual root cause (e.g., HTTP 500 from
|
// errors because they carry the actual root cause (e.g., HTTP 500 from
|
||||||
// the main page). When we cancel taskCtx on a main page error,
|
// the main page). When we cancel taskCtx on a main page error,
|
||||||
|
|||||||
61
pkg/modules/chromium/browser_test.go
Normal file
61
pkg/modules/chromium/browser_test.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package chromium
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"slices"
|
||||||
|
"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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChromiumDisableFeatures guards the override described in
|
||||||
|
// https://github.com/gotenberg/gotenberg/issues/1656. Gotenberg replaces
|
||||||
|
// chromedp's --disable-features value rather than extending it, as
|
||||||
|
// chromedp.Flag keys its flags by switch name. Dropping one of chromedp's own
|
||||||
|
// entries while editing this list would silently re-enable it.
|
||||||
|
func TestChromiumDisableFeatures(t *testing.T) {
|
||||||
|
for _, feature := range []string{
|
||||||
|
// chromedp.DefaultExecAllocatorOptions.
|
||||||
|
"site-per-process",
|
||||||
|
"Translate",
|
||||||
|
"BlinkGenPropertyTrees",
|
||||||
|
// The address-bar popup WebUI, built even in headless.
|
||||||
|
"WebUIOmniboxPopup",
|
||||||
|
"WebUIOmniboxAimPopup",
|
||||||
|
} {
|
||||||
|
if !slices.Contains(strings.Split(chromiumDisableFeatures, ","), feature) {
|
||||||
|
t.Errorf("expected %q to be disabled, got %q", feature, chromiumDisableFeatures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -42,6 +43,10 @@ var (
|
|||||||
// or undefined.
|
// or undefined.
|
||||||
ErrInvalidSelectorQuery = errors.New("invalid selector query")
|
ErrInvalidSelectorQuery = errors.New("invalid selector query")
|
||||||
|
|
||||||
|
// ErrScreenshotSelectorNotFound happens when the CSS selector of a
|
||||||
|
// screenshot matches no element with a rendered box.
|
||||||
|
ErrScreenshotSelectorNotFound = errors.New("screenshot selector not found")
|
||||||
|
|
||||||
// ErrRpccMessageTooLarge happens when the messages received by
|
// ErrRpccMessageTooLarge happens when the messages received by
|
||||||
// ChromeDevTools are larger than 100 MB.
|
// ChromeDevTools are larger than 100 MB.
|
||||||
ErrRpccMessageTooLarge = errors.New("rpcc message too large")
|
ErrRpccMessageTooLarge = errors.New("rpcc message too large")
|
||||||
@@ -66,6 +71,10 @@ var (
|
|||||||
// ErrResourceLoadingFailed happens when one or more resources failed to load.
|
// ErrResourceLoadingFailed happens when one or more resources failed to load.
|
||||||
ErrResourceLoadingFailed = errors.New("resource loading failed")
|
ErrResourceLoadingFailed = errors.New("resource loading failed")
|
||||||
|
|
||||||
|
// ErrChromiumCrashed happens when the Chromium renderer crashes during a
|
||||||
|
// conversion.
|
||||||
|
ErrChromiumCrashed = errors.New("chromium crashed")
|
||||||
|
|
||||||
// PDF specific.
|
// PDF specific.
|
||||||
|
|
||||||
// ErrOmitBackgroundWithoutPrintBackground happens if
|
// ErrOmitBackgroundWithoutPrintBackground happens if
|
||||||
@@ -101,12 +110,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 +308,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)
|
||||||
@@ -340,6 +355,11 @@ type ScreenshotOptions struct {
|
|||||||
// dimensions.
|
// dimensions.
|
||||||
Clip bool
|
Clip bool
|
||||||
|
|
||||||
|
// Selector clips the screenshot to the bounding box of the first element
|
||||||
|
// matching this CSS selector. Empty captures the whole page. Takes
|
||||||
|
// precedence over Clip.
|
||||||
|
Selector string
|
||||||
|
|
||||||
// Format is the image compression format, either "png" or "jpeg" or
|
// Format is the image compression format, either "png" or "jpeg" or
|
||||||
// "webp".
|
// "webp".
|
||||||
Format string
|
Format string
|
||||||
@@ -363,6 +383,7 @@ func DefaultScreenshotOptions() ScreenshotOptions {
|
|||||||
Width: 800,
|
Width: 800,
|
||||||
Height: 600,
|
Height: 600,
|
||||||
Clip: false,
|
Clip: false,
|
||||||
|
Selector: "",
|
||||||
Format: "png",
|
Format: "png",
|
||||||
Quality: 100,
|
Quality: 100,
|
||||||
OptimizeForSpeed: false,
|
OptimizeForSpeed: false,
|
||||||
@@ -453,12 +474,14 @@ 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.StringSlice("chromium-allow-list", []string{}, "Set the allowed URLs for Chromium using regular expressions - supports multiple values")
|
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. A match bypasses --chromium-deny-private-ips (CHROMIUM_DENY_PRIVATE_IPS) and --chromium-deny-public-ips (CHROMIUM_DENY_PUBLIC_IPS), so terminate the host or the pattern also matches suffix hosts, for example ^https?://internal\.svc(:|/|$)`)
|
||||||
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")
|
||||||
fs.Bool("chromium-deny-public-ips", false, "Reject 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("chromium-deny-public-ips", false, "Reject 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("chromium-clear-cache", false, "Clear Chromium cache between each conversion")
|
fs.Bool("chromium-clear-cache", false, "Clear Chromium cache between each conversion")
|
||||||
fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion")
|
fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion")
|
||||||
|
fs.Bool("chromium-clear-storage", false, "Clear Chromium local storage between each conversion (session storage is already isolated per conversion)")
|
||||||
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
|
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
|
||||||
fs.Bool("chromium-disable-routes", false, "Disable the routes")
|
fs.Bool("chromium-disable-routes", false, "Disable the routes")
|
||||||
|
|
||||||
@@ -484,12 +507,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{
|
||||||
@@ -500,6 +523,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,
|
||||||
|
|
||||||
@@ -509,6 +533,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
|||||||
denyPublicIPs: flags.MustBool("chromium-deny-public-ips"),
|
denyPublicIPs: flags.MustBool("chromium-deny-public-ips"),
|
||||||
clearCache: flags.MustBool("chromium-clear-cache"),
|
clearCache: flags.MustBool("chromium-clear-cache"),
|
||||||
clearCookies: flags.MustBool("chromium-clear-cookies"),
|
clearCookies: flags.MustBool("chromium-clear-cookies"),
|
||||||
|
clearStorage: flags.MustBool("chromium-clear-storage"),
|
||||||
disableJavaScript: flags.MustBool("chromium-disable-javascript"),
|
disableJavaScript: flags.MustBool("chromium-disable-javascript"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,7 +542,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
|||||||
|
|
||||||
// Process.
|
// Process.
|
||||||
mod.browser = newChromiumBrowser(mod.args)
|
mod.browser = newChromiumBrowser(mod.args)
|
||||||
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"), mod.maxConcurrency, flags.MustDuration("chromium-idle-shutdown-timeout"))
|
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, "chromium", mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"), mod.maxConcurrency, flags.MustDuration("chromium-idle-shutdown-timeout"))
|
||||||
|
|
||||||
// PDF Engine.
|
// PDF Engine.
|
||||||
provider, err := ctx.Module(new(gotenberg.PdfEngineProvider))
|
provider, err := ctx.Module(new(gotenberg.PdfEngineProvider))
|
||||||
@@ -633,6 +658,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -642,14 +685,21 @@ func (mod *Chromium) Validate() error {
|
|||||||
return fmt.Errorf("chromium-max-concurrency must be between 1 and 6, got %d", mod.maxConcurrency)
|
return fmt.Errorf("chromium-max-concurrency must be between 1 and 6, got %d", mod.maxConcurrency)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if mod.args.enableEnvironmentProxy {
|
||||||
|
proxyErr := gotenberg.ValidateEnvironmentProxyVariables()
|
||||||
|
if proxyErr != nil {
|
||||||
|
return fmt.Errorf("--chromium-enable-environment-proxy is set: %w", proxyErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_, err := os.Stat(mod.args.binPath)
|
_, err := os.Stat(mod.args.binPath)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return fmt.Errorf("chromium binary path does not exist: %w", err)
|
return fmt.Errorf("Chromium binary does not exist at %q; check the CHROMIUM_BIN_PATH environment variable: %w", mod.args.binPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = os.Stat(mod.args.hyphenDataDirPath)
|
_, err = os.Stat(mod.args.hyphenDataDirPath)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return fmt.Errorf("chromium hyphen-data directory path does not exist: %w", err)
|
return fmt.Errorf("Chromium hyphenation data directory does not exist at %q; check the CHROMIUM_HYPHEN_DATA_DIR_PATH environment variable (it ships in the Gotenberg image): %w", mod.args.hyphenDataDirPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -697,19 +747,46 @@ func (mod *Chromium) Stop(ctx context.Context) error {
|
|||||||
|
|
||||||
// Debug returns additional debug data.
|
// Debug returns additional debug data.
|
||||||
func (mod *Chromium) Debug() map[string]any {
|
func (mod *Chromium) Debug() map[string]any {
|
||||||
debug := make(map[string]any)
|
return map[string]any{"version": mod.detectVersion()}
|
||||||
|
}
|
||||||
|
|
||||||
cmd := exec.Command(mod.args.binPath, "--version") //nolint:gosec
|
// detectVersion resolves the Chromium version once, preferring the value
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
// captured at image build time so it never spawns Chromium at runtime. It falls
|
||||||
|
// back to running chromium --version for local or non-Docker builds.
|
||||||
|
func (mod *Chromium) detectVersion() string {
|
||||||
|
mod.versionOnce.Do(func() {
|
||||||
|
if v, ok := gotenberg.BuildVersion("chromium"); ok {
|
||||||
|
mod.version = v
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
output, err := cmd.Output()
|
cmd := exec.Command(mod.args.binPath, "--version") //nolint:gosec
|
||||||
if err != nil {
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
debug["version"] = err.Error()
|
|
||||||
return debug
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
mod.version = err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mod.version = strings.TrimSpace(string(output))
|
||||||
|
})
|
||||||
|
|
||||||
|
return mod.version
|
||||||
|
}
|
||||||
|
|
||||||
|
// spanAttrs returns the client-span attributes for a Chromium invocation: the
|
||||||
|
// server address and the Chromium version, plus any extra attributes. The
|
||||||
|
// version rides on every conversion span so a trace records which Chromium
|
||||||
|
// rendered the document.
|
||||||
|
func (mod *Chromium) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue {
|
||||||
|
attrs := make([]attribute.KeyValue, 0, 2+len(extra))
|
||||||
|
attrs = append(attrs, semconv.ServerAddress(mod.args.binPath))
|
||||||
|
if v := mod.detectVersion(); v != "" {
|
||||||
|
attrs = append(attrs, attribute.String("gotenberg.chromium.version", v))
|
||||||
}
|
}
|
||||||
|
|
||||||
debug["version"] = strings.TrimSpace(string(output))
|
return append(attrs, extra...)
|
||||||
return debug
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metrics returns the metrics.
|
// Metrics returns the metrics.
|
||||||
@@ -799,19 +876,32 @@ func (mod *Chromium) Routes() ([]api.Route, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pdf converts a URL to PDF.
|
// Pdf converts a URL to PDF.
|
||||||
|
//
|
||||||
|
//nolint:dupl
|
||||||
func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
|
func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
|
||||||
|
// Read input attributes before Start rebinds ctx to the span context, which
|
||||||
|
// would shadow the underlying [api.Context].
|
||||||
|
inputAttrs := conversionInputAttrs(ctx, url)
|
||||||
|
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Pdf",
|
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Pdf",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)),
|
trace.WithAttributes(mod.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
|
span.SetAttributes(inputAttrs...)
|
||||||
|
span.SetAttributes(
|
||||||
|
attribute.Int64("gotenberg.queue.depth_at_arrival", mod.supervisor.ReqQueueSize()),
|
||||||
|
attribute.Int64("gotenberg.conversions_since_last_restart", mod.supervisor.ConversionsSinceRestart()),
|
||||||
|
)
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
var conversionStart time.Time
|
var conversionStart time.Time
|
||||||
|
|
||||||
|
aggregate := newNetworkAggregate()
|
||||||
err := mod.supervisor.Run(ctx, logger, func() error {
|
err := mod.supervisor.Run(ctx, logger, func() error {
|
||||||
conversionStart = time.Now()
|
conversionStart = time.Now()
|
||||||
return mod.browser.pdf(ctx, logger, url, outputPath, options)
|
return mod.browser.pdf(ctx, logger, url, outputPath, options, aggregate)
|
||||||
})
|
})
|
||||||
|
|
||||||
end := time.Now()
|
end := time.Now()
|
||||||
@@ -824,21 +914,12 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
|
|||||||
status = "error"
|
status = "error"
|
||||||
}
|
}
|
||||||
|
|
||||||
reason := "unknown"
|
reason := chromiumErrorType(err, "chromium_unavailable")
|
||||||
switch {
|
|
||||||
case errors.Is(err, context.DeadlineExceeded):
|
|
||||||
reason = "timeout"
|
|
||||||
case errors.Is(err, context.Canceled):
|
|
||||||
reason = "context_cancelled"
|
|
||||||
case errors.Is(err, ErrInvalidHttpStatusCode) || errors.Is(err, ErrInvalidResourceHttpStatusCode) || errors.Is(err, ErrLoadingFailed) || errors.Is(err, ErrResourceLoadingFailed) || errors.Is(err, ErrInvalidEvaluationExpression) || errors.Is(err, ErrInvalidSelectorQuery):
|
|
||||||
reason = "invalid_input"
|
|
||||||
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) || errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
|
|
||||||
reason = "chromium_unavailable"
|
|
||||||
}
|
|
||||||
|
|
||||||
mod.errsCounter.Add(ctx, 1, metric.WithAttributes(
|
mod.errsCounter.Add(ctx, 1, metric.WithAttributes(
|
||||||
attribute.String("reason", reason),
|
attribute.String("reason", reason),
|
||||||
))
|
))
|
||||||
|
gotenberg.SpanErrorType(span, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !conversionStart.IsZero() {
|
if !conversionStart.IsZero() {
|
||||||
@@ -862,9 +943,12 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
|
|||||||
attribute.String("status", status),
|
attribute.String("status", status),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
mod.recordNetwork(ctx, span, aggregate)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
|
||||||
mod.pdfOutputSizeCounter.Record(ctx, fileInfo.Size())
|
mod.pdfOutputSizeCounter.Record(ctx, fileInfo.Size())
|
||||||
|
span.SetAttributes(attribute.Int64("gotenberg.conversion.output.bytes", fileInfo.Size()))
|
||||||
}
|
}
|
||||||
|
|
||||||
span.SetStatus(codes.Ok, "")
|
span.SetStatus(codes.Ok, "")
|
||||||
@@ -876,19 +960,28 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Screenshot captures a screenshot from a URL.
|
||||||
|
//
|
||||||
|
//nolint:dupl
|
||||||
func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
|
func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Screenshot",
|
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Screenshot",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)),
|
trace.WithAttributes(mod.spanAttrs()...),
|
||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
|
span.SetAttributes(
|
||||||
|
attribute.Int64("gotenberg.queue.depth_at_arrival", mod.supervisor.ReqQueueSize()),
|
||||||
|
attribute.Int64("gotenberg.conversions_since_last_restart", mod.supervisor.ConversionsSinceRestart()),
|
||||||
|
)
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
var conversionStart time.Time
|
var conversionStart time.Time
|
||||||
|
|
||||||
|
aggregate := newNetworkAggregate()
|
||||||
err := mod.supervisor.Run(ctx, logger, func() error {
|
err := mod.supervisor.Run(ctx, logger, func() error {
|
||||||
conversionStart = time.Now()
|
conversionStart = time.Now()
|
||||||
return mod.browser.screenshot(ctx, logger, url, outputPath, options)
|
return mod.browser.screenshot(ctx, logger, url, outputPath, options, aggregate)
|
||||||
})
|
})
|
||||||
|
|
||||||
end := time.Now()
|
end := time.Now()
|
||||||
@@ -901,23 +994,12 @@ func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, o
|
|||||||
status = "error"
|
status = "error"
|
||||||
}
|
}
|
||||||
|
|
||||||
reason := "unknown"
|
reason := chromiumErrorType(err, "chromium_maximum_queue_size_exceeded")
|
||||||
switch {
|
|
||||||
case errors.Is(err, context.DeadlineExceeded):
|
|
||||||
reason = "timeout"
|
|
||||||
case errors.Is(err, context.Canceled):
|
|
||||||
reason = "context_cancelled"
|
|
||||||
case errors.Is(err, ErrInvalidHttpStatusCode) || errors.Is(err, ErrInvalidResourceHttpStatusCode) || errors.Is(err, ErrLoadingFailed) || errors.Is(err, ErrResourceLoadingFailed) || errors.Is(err, ErrInvalidEvaluationExpression) || errors.Is(err, ErrInvalidSelectorQuery):
|
|
||||||
reason = "invalid_input"
|
|
||||||
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded):
|
|
||||||
reason = "chromium_maximum_queue_size_exceeded"
|
|
||||||
case errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
|
|
||||||
reason = "chromium_unavailable"
|
|
||||||
}
|
|
||||||
|
|
||||||
mod.errsCounter.Add(ctx, 1, metric.WithAttributes(
|
mod.errsCounter.Add(ctx, 1, metric.WithAttributes(
|
||||||
attribute.String("reason", reason),
|
attribute.String("reason", reason),
|
||||||
))
|
))
|
||||||
|
gotenberg.SpanErrorType(span, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !conversionStart.IsZero() {
|
if !conversionStart.IsZero() {
|
||||||
@@ -941,6 +1023,8 @@ func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, o
|
|||||||
attribute.String("status", status),
|
attribute.String("status", status),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
mod.recordNetwork(ctx, span, aggregate)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
|
||||||
mod.imageOutputSizeCounter.Record(ctx, fileInfo.Size())
|
mod.imageOutputSizeCounter.Record(ctx, fileInfo.Size())
|
||||||
@@ -955,6 +1039,88 @@ 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, ErrChromiumCrashed):
|
||||||
|
return "chromium_unavailable"
|
||||||
|
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)
|
||||||
|
|||||||
38
pkg/modules/chromium/errortype_test.go
Normal file
38
pkg/modules/chromium/errortype_test.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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"},
|
||||||
|
{"crashed", ErrChromiumCrashed, "chromium_unavailable", "chromium_unavailable"},
|
||||||
|
{"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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,9 +10,11 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/chromedp/cdproto/cdp"
|
"github.com/chromedp/cdproto/cdp"
|
||||||
"github.com/chromedp/cdproto/fetch"
|
"github.com/chromedp/cdproto/fetch"
|
||||||
|
"github.com/chromedp/cdproto/inspector"
|
||||||
"github.com/chromedp/cdproto/network"
|
"github.com/chromedp/cdproto/network"
|
||||||
"github.com/chromedp/cdproto/page"
|
"github.com/chromedp/cdproto/page"
|
||||||
"github.com/chromedp/cdproto/runtime"
|
"github.com/chromedp/cdproto/runtime"
|
||||||
@@ -23,6 +25,74 @@ 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 eventWebSocketCreatedOptions struct {
|
||||||
|
allowList, denyList []*regexp2.Regexp
|
||||||
|
denyPrivateIPs bool
|
||||||
|
denyPublicIPs bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// listenForEventWebSocketCreated validates the target of every WebSocket
|
||||||
|
// handshake against the same allow / deny lists and IP-class policy as
|
||||||
|
// [listenForEventRequestPaused]. Chromium never surfaces a WebSocket
|
||||||
|
// handshake as a fetch.EventRequestPaused, so without this listener a page
|
||||||
|
// could open a WebSocket to an address the outbound filter would otherwise
|
||||||
|
// block. See https://github.com/gotenberg/gotenberg/issues/1011.
|
||||||
|
//
|
||||||
|
// This listener records an operator-visible warning with the full ws:// URL.
|
||||||
|
// The connection itself is severed by the pinning proxy, which every
|
||||||
|
// WebSocket handshake traverses once the implicit loopback / link-local proxy
|
||||||
|
// bypass is removed (see the "<-loopback>" flag in browser.go). When the
|
||||||
|
// operator configures a custom proxy or host-resolver mappings, the pinning
|
||||||
|
// proxy is not started; the WebSocket then follows the operator's egress path
|
||||||
|
// and this warning is the remaining safeguard, since a WebSocket handshake
|
||||||
|
// cannot be aborted through the CDP Network domain.
|
||||||
|
func listenForEventWebSocketCreated(ctx context.Context, logger *slog.Logger, options eventWebSocketCreatedOptions) {
|
||||||
|
chromedp.ListenTarget(ctx, func(ev any) {
|
||||||
|
e, ok := ev.(*network.EventWebSocketCreated)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
logger.DebugContext(ctx, fmt.Sprintf("event EventWebSocketCreated fired for '%s'", e.URL))
|
||||||
|
|
||||||
|
deadline, ok := ctx.Deadline()
|
||||||
|
if !ok {
|
||||||
|
logger.ErrorContext(ctx, "context has no deadline, cannot filter WebSocket URL")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := gotenberg.FilterOutboundURL(ctx, e.URL, options.allowList, options.denyList, deadline,
|
||||||
|
gotenberg.WithDenyPrivateIPs(options.denyPrivateIPs),
|
||||||
|
gotenberg.WithDenyPublicIPs(options.denyPublicIPs),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnContext(ctx, err.Error())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type eventRequestPausedOptions struct {
|
type eventRequestPausedOptions struct {
|
||||||
allowList, denyList []*regexp2.Regexp
|
allowList, denyList []*regexp2.Regexp
|
||||||
denyPrivateIPs bool
|
denyPrivateIPs bool
|
||||||
@@ -42,6 +112,11 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
|
|||||||
logger.DebugContext(ctx, fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders))
|
logger.DebugContext(ctx, fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared by every scope match of this conversion, across all paused
|
||||||
|
// requests. Its lifetime is the conversion, as this function is called once
|
||||||
|
// per conversion with that conversion's context.
|
||||||
|
budget := newScopeMatchBudget(scopeMatchBudgetPerConversion)
|
||||||
|
|
||||||
chromedp.ListenTarget(ctx, func(ev any) {
|
chromedp.ListenTarget(ctx, func(ev any) {
|
||||||
if e, ok := ev.(*fetch.EventRequestPaused); ok {
|
if e, ok := ev.(*fetch.EventRequestPaused); ok {
|
||||||
go func() {
|
go func() {
|
||||||
@@ -107,6 +182,14 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
|
|||||||
// First, we have to check if at least one header has to be
|
// First, we have to check if at least one header has to be
|
||||||
// set for the current request.
|
// set for the current request.
|
||||||
for _, header := range options.extraHttpHeaders {
|
for _, header := range options.extraHttpHeaders {
|
||||||
|
// This goroutine outlives the response: nothing cancels an
|
||||||
|
// in-flight match, so stop as soon as the conversion is over.
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
if header.Scope == nil {
|
if header.Scope == nil {
|
||||||
// Non-scoped header.
|
// Non-scoped header.
|
||||||
logger.DebugContext(ctx, fmt.Sprintf("extra HTTP header '%s' will be set for request URL '%s'", header.Name, e.Request.URL))
|
logger.DebugContext(ctx, fmt.Sprintf("extra HTTP header '%s' will be set for request URL '%s'", header.Name, e.Request.URL))
|
||||||
@@ -114,7 +197,18 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !budget.tryAcquire() {
|
||||||
|
// Treat the remaining scoped headers as non-matching rather
|
||||||
|
// than spending more CPU on a request the client may already
|
||||||
|
// have given up on.
|
||||||
|
logger.WarnContext(ctx, fmt.Sprintf("scope matching budget of %s exhausted, extra HTTP header '%s' and any subsequent scoped header will not be set; simplify the 'scope' patterns or reduce the number of scoped headers", scopeMatchBudgetPerConversion, header.Name))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
matchStart := time.Now()
|
||||||
ok, err := header.Scope.MatchString(e.Request.URL)
|
ok, err := header.Scope.MatchString(e.Request.URL)
|
||||||
|
budget.consume(time.Since(matchStart))
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case err != nil:
|
case err != nil:
|
||||||
logger.ErrorContext(ctx, fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err))
|
logger.ErrorContext(ctx, fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err))
|
||||||
@@ -229,7 +323,15 @@ func listenForEventResponseReceived(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugContext(ctx, fmt.Sprintf("event EventResponseReceived fired for a resource: %+v", ev.Response))
|
// Formatting the whole response is the most expensive thing this
|
||||||
|
// listener does, and it runs per sub-resource on chromedp's single
|
||||||
|
// per-target event goroutine while that goroutine holds the mutex
|
||||||
|
// it also takes to dispatch command responses. At the default log
|
||||||
|
// level the result is discarded, so gate it on the level rather
|
||||||
|
// than let slog drop it after the fact.
|
||||||
|
if logger.Enabled(ctx, slog.LevelDebug) {
|
||||||
|
logger.DebugContext(ctx, fmt.Sprintf("event EventResponseReceived fired for a resource: %+v", ev.Response))
|
||||||
|
}
|
||||||
|
|
||||||
if slices.Contains(options.failOnResourceOnHttpStatusCode, ev.Response.Status) {
|
if slices.Contains(options.failOnResourceOnHttpStatusCode, ev.Response.Status) {
|
||||||
if !shouldCheckResourceHttpStatusCode(ev.Response.URL, normalizedIgnoreDomains) {
|
if !shouldCheckResourceHttpStatusCode(ev.Response.URL, normalizedIgnoreDomains) {
|
||||||
@@ -428,6 +530,38 @@ func listenForEventExceptionThrown(ctx context.Context, logger *slog.Logger, con
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type eventTargetCrashedOptions struct {
|
||||||
|
crashed *error
|
||||||
|
crashedMu *sync.RWMutex
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// listenForEventTargetCrashed listens for the Inspector.targetCrashed event,
|
||||||
|
// which Chromium sends when the renderer serving the conversion's tab
|
||||||
|
// crashes. chromedp enables the Inspector domain on every target but does
|
||||||
|
// not handle this event: left alone, the in-flight CDP command never
|
||||||
|
// receives a response and the conversion blocks until the request deadline.
|
||||||
|
// Record the crash and cancel the task context so the conversion fails fast
|
||||||
|
// instead.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1640.
|
||||||
|
func listenForEventTargetCrashed(ctx context.Context, logger *slog.Logger, options eventTargetCrashedOptions) {
|
||||||
|
chromedp.ListenTarget(ctx, func(ev any) {
|
||||||
|
if _, ok := ev.(*inspector.EventTargetCrashed); ok {
|
||||||
|
logger.DebugContext(ctx, "event EventTargetCrashed fired")
|
||||||
|
|
||||||
|
options.crashedMu.Lock()
|
||||||
|
defer options.crashedMu.Unlock()
|
||||||
|
|
||||||
|
*options.crashed = ErrChromiumCrashed
|
||||||
|
|
||||||
|
// Cancel the task context so the in-flight CDP command aborts
|
||||||
|
// immediately instead of waiting for a response the crashed
|
||||||
|
// renderer can never send.
|
||||||
|
options.cancel()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// waitForEventDomContentEventFired registers a listener for the
|
// waitForEventDomContentEventFired registers a listener for the
|
||||||
// DomContentEventFired event and returns a waiter that blocks until the
|
// DomContentEventFired event and returns a waiter that blocks until the
|
||||||
// event fires or ctx is done. The listener registers at call time, not
|
// event fires or ctx is done. The listener registers at call time, not
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
140
pkg/modules/chromium/network_aggregate.go
Normal file
140
pkg/modules/chromium/network_aggregate.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
// maxTrackedRequests bounds the request id to URL map. Entries are dropped as
|
||||||
|
// soon as the request settles, so the map normally holds only what is in
|
||||||
|
// flight, but a request that never reports a loading-finished or
|
||||||
|
// loading-failed event never settles. Without a cap, a page that opens
|
||||||
|
// requests it never resolves would grow the map for the whole conversion, at
|
||||||
|
// the cost of one full response URL per entry. Losing an entry only costs the
|
||||||
|
// heaviest-resource URL attribution for that request.
|
||||||
|
const maxTrackedRequests = 1024
|
||||||
|
|
||||||
|
// 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{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(a.requestURLByID) < maxTrackedRequests {
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
|
||||||
|
// The request has settled and nothing reads its URL again. Dropping it
|
||||||
|
// keeps the map proportional to the requests in flight rather than to
|
||||||
|
// every request the page ever made.
|
||||||
|
delete(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++
|
||||||
|
|
||||||
|
// Settled, like a finished request: its URL is never read again.
|
||||||
|
delete(a.requestURLByID, ev.RequestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
183
pkg/modules/chromium/network_aggregate_test.go
Normal file
183
pkg/modules/chromium/network_aggregate_test.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNetworkAggregate_SettledRequestsAreDropped covers the growth where every
|
||||||
|
// response URL stayed in the map for the whole conversion even though nothing
|
||||||
|
// reads it again once the request settles.
|
||||||
|
func TestNetworkAggregate_SettledRequestsAreDropped(t *testing.T) {
|
||||||
|
a := newNetworkAggregate()
|
||||||
|
|
||||||
|
for i := range 500 {
|
||||||
|
id := network.RequestID(fmt.Sprintf("r%d", i))
|
||||||
|
a.onResponseReceived(&network.EventResponseReceived{
|
||||||
|
RequestID: id,
|
||||||
|
Response: &network.Response{URL: fmt.Sprintf("https://host.example.com/%d", i)},
|
||||||
|
})
|
||||||
|
|
||||||
|
if i%2 == 0 {
|
||||||
|
a.onLoadingFinished(&network.EventLoadingFinished{RequestID: id, EncodedDataLength: 10})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
a.onLoadingFailed(&network.EventLoadingFailed{RequestID: id})
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
tracked := len(a.requestURLByID)
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if tracked != 0 {
|
||||||
|
t.Errorf("tracked requests = %d, want 0: settled requests must not be retained", tracked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bookkeeping the map feeds must survive the pruning.
|
||||||
|
got := a.snapshot()
|
||||||
|
if got.requestCount != 500 {
|
||||||
|
t.Errorf("requestCount = %d, want 500", got.requestCount)
|
||||||
|
}
|
||||||
|
if got.failedCount != 250 {
|
||||||
|
t.Errorf("failedCount = %d, want 250", got.failedCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNetworkAggregate_UnsettledRequestCap verifies the ceiling that applies
|
||||||
|
// when requests never settle, which is the only way the map can still grow.
|
||||||
|
func TestNetworkAggregate_UnsettledRequestCap(t *testing.T) {
|
||||||
|
a := newNetworkAggregate()
|
||||||
|
|
||||||
|
for i := range maxTrackedRequests + 500 {
|
||||||
|
a.onResponseReceived(&network.EventResponseReceived{
|
||||||
|
RequestID: network.RequestID(fmt.Sprintf("r%d", i)),
|
||||||
|
Response: &network.Response{URL: fmt.Sprintf("https://host.example.com/%d", i)},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
tracked := len(a.requestURLByID)
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if tracked != maxTrackedRequests {
|
||||||
|
t.Errorf("tracked requests = %d, want %d (capped)", tracked, maxTrackedRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNetworkAggregate_HeaviestURLSurvivesPruning guards the attribution the
|
||||||
|
// map exists for: the URL must still be resolved before the entry is dropped.
|
||||||
|
func TestNetworkAggregate_HeaviestURLSurvivesPruning(t *testing.T) {
|
||||||
|
a := newNetworkAggregate()
|
||||||
|
|
||||||
|
a.onResponseReceived(&network.EventResponseReceived{
|
||||||
|
RequestID: "small",
|
||||||
|
Response: &network.Response{URL: "https://example.com/small.css"},
|
||||||
|
})
|
||||||
|
a.onLoadingFinished(&network.EventLoadingFinished{RequestID: "small", EncodedDataLength: 10})
|
||||||
|
|
||||||
|
a.onResponseReceived(&network.EventResponseReceived{
|
||||||
|
RequestID: "big",
|
||||||
|
Response: &network.Response{URL: "https://example.com/big.png"},
|
||||||
|
})
|
||||||
|
a.onLoadingFinished(&network.EventLoadingFinished{RequestID: "big", EncodedDataLength: 4096})
|
||||||
|
|
||||||
|
got := a.snapshot()
|
||||||
|
if got.heaviestURL != "https://example.com/big.png" || got.heaviestBytes != 4096 {
|
||||||
|
t.Errorf("heaviest = (%q, %d), want (%q, 4096)", got.heaviestURL, got.heaviestBytes, "https://example.com/big.png")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,10 +9,13 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"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,10 +46,30 @@ 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
|
||||||
|
|
||||||
|
// closing is closed by Stop to force in-flight CONNECT tunnels shut.
|
||||||
|
// [http.Server.Shutdown] cannot do it: net/http untracks a connection once
|
||||||
|
// a handler hijacks it, so a tunnel would otherwise outlive the proxy that
|
||||||
|
// created it. Recreated on every Start.
|
||||||
|
closing chan struct{}
|
||||||
|
|
||||||
|
// maxTunnels ceilings the CONNECT handlers in flight. Tests may lower it.
|
||||||
|
maxTunnels int64
|
||||||
|
|
||||||
|
// tunnels counts the CONNECT handlers in flight.
|
||||||
|
tunnels atomic.Int64
|
||||||
|
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
started bool
|
started bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -57,8 +80,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) {
|
||||||
@@ -72,7 +95,16 @@ func newPinningProxy(allowList, denyList []*regexp2.Regexp, denyPrivateIPs, deny
|
|||||||
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||||||
return dialer.DialContext(ctx, network, addr)
|
return dialer.DialContext(ctx, network, addr)
|
||||||
},
|
},
|
||||||
|
maxTunnels: maxConcurrentTunnels,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -92,6 +124,7 @@ func (p *pinningProxy) Start(logger *slog.Logger) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
p.listener = l
|
p.listener = l
|
||||||
|
p.closing = make(chan struct{})
|
||||||
p.logger = logger.With(slog.String("logger", "pinning-proxy"))
|
p.logger = logger.With(slog.String("logger", "pinning-proxy"))
|
||||||
p.server = &http.Server{
|
p.server = &http.Server{
|
||||||
Handler: http.HandlerFunc(p.serveHTTP),
|
Handler: http.HandlerFunc(p.serveHTTP),
|
||||||
@@ -122,9 +155,18 @@ func (p *pinningProxy) Stop(logger *slog.Logger) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
srv := p.server
|
srv := p.server
|
||||||
|
closing := p.closing
|
||||||
|
p.closing = nil
|
||||||
p.started = false
|
p.started = false
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
// Force in-flight tunnels shut before draining the server. Shutdown does
|
||||||
|
// not reach them, so a tunnel whose upstream never answers would otherwise
|
||||||
|
// survive the proxy, and with it every Chromium restart.
|
||||||
|
if closing != nil {
|
||||||
|
close(closing)
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -163,6 +205,25 @@ func (p *pinningProxy) serveHTTP(w http.ResponseWriter, req *http.Request) {
|
|||||||
// Chromium then negotiates TLS end-to-end with the original hostname in
|
// Chromium then negotiates TLS end-to-end with the original hostname in
|
||||||
// SNI.
|
// SNI.
|
||||||
func (p *pinningProxy) handleConnect(w http.ResponseWriter, req *http.Request) {
|
func (p *pinningProxy) handleConnect(w http.ResponseWriter, req *http.Request) {
|
||||||
|
// A ceiling, not a tuning knob: it bounds what a tunnel that refuses to end
|
||||||
|
// can accumulate, whatever keeps it alive. [spliceIdleTimeout] ends a silent
|
||||||
|
// tunnel, but a peer trickling a byte just under it stays "active" forever,
|
||||||
|
// and a compromised renderer can hold the client side open to match.
|
||||||
|
//
|
||||||
|
// Chromium caps itself well below this. Its socket pool manager allows 128
|
||||||
|
// sockets per proxy chain for normal traffic plus 128 for WebSocket
|
||||||
|
// traffic, and every request Gotenberg's Chromium makes traverses this one
|
||||||
|
// proxy chain, so an honest browser cannot exceed 256 tunnels here. At
|
||||||
|
// double that, a real page never meets the ceiling and a hostile one stops
|
||||||
|
// at it.
|
||||||
|
if !p.acquireTunnel() {
|
||||||
|
p.logger.WarnContext(req.Context(), fmt.Sprintf("CONNECT to '%s' refused: %d tunnels already in flight", req.Host, p.maxTunnels))
|
||||||
|
http.Error(w, "too many tunnels", http.StatusServiceUnavailable)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer p.releaseTunnel()
|
||||||
|
|
||||||
_, port, err := net.SplitHostPort(req.Host)
|
_, port, err := net.SplitHostPort(req.Host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "bad CONNECT target", http.StatusBadRequest)
|
http.Error(w, "bad CONNECT target", http.StatusBadRequest)
|
||||||
@@ -188,8 +249,24 @@ func (p *pinningProxy) handleConnect(w http.ResponseWriter, req *http.Request) {
|
|||||||
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:
|
||||||
@@ -230,24 +307,142 @@ func (p *pinningProxy) handleConnect(w http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Splice bytes in both directions until either side closes.
|
p.mu.Lock()
|
||||||
var splice sync.WaitGroup
|
closing := p.closing
|
||||||
splice.Add(2)
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
spliceTunnel(client, upstream, closing, spliceIdleTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxConcurrentTunnels is the default for [pinningProxy.maxTunnels]. See
|
||||||
|
// [pinningProxy.handleConnect] for how the value is derived.
|
||||||
|
const maxConcurrentTunnels = 512
|
||||||
|
|
||||||
|
// acquireTunnel reserves a slot for one CONNECT handler, reporting false when
|
||||||
|
// the proxy is already at [pinningProxy.maxTunnels]. The compare-and-swap loop
|
||||||
|
// keeps the check and the increment atomic, so concurrent handlers cannot
|
||||||
|
// overshoot the ceiling between them.
|
||||||
|
func (p *pinningProxy) acquireTunnel() bool {
|
||||||
|
for {
|
||||||
|
current := p.tunnels.Load()
|
||||||
|
if current >= p.maxTunnels {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if p.tunnels.CompareAndSwap(current, current+1) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseTunnel returns a slot taken by [pinningProxy.acquireTunnel].
|
||||||
|
func (p *pinningProxy) releaseTunnel() {
|
||||||
|
p.tunnels.Add(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// spliceIdleTimeout bounds a CONNECT tunnel in which no byte has moved in
|
||||||
|
// either direction.
|
||||||
|
//
|
||||||
|
// Nothing else bounds one. The hijacked connections carry no deadline: the
|
||||||
|
// server clears the header read deadline once the request line is in, and
|
||||||
|
// net.Dialer.Timeout only covers the connect. net/http also untracks a
|
||||||
|
// connection once it is hijacked, so neither Server.Shutdown nor a Chromium
|
||||||
|
// restart reaps it. Left alone, an upstream that accepts the tunnel and then
|
||||||
|
// answers nothing holds two goroutines and two sockets until the process dies.
|
||||||
|
//
|
||||||
|
// Sized well above any legitimate pause between a request and its response, so
|
||||||
|
// a slow origin is never cut off. A transfer that keeps making progress
|
||||||
|
// refreshes the deadline and runs for as long as it needs.
|
||||||
|
const spliceIdleTimeout = 2 * time.Minute
|
||||||
|
|
||||||
|
// spliceTunnel copies bytes between the two ends of a CONNECT tunnel until
|
||||||
|
// both directions finish, the tunnel sits idle for idleTimeout, or closing is
|
||||||
|
// closed because the proxy is shutting down. Callers pass
|
||||||
|
// [spliceIdleTimeout]; only tests shorten it.
|
||||||
|
//
|
||||||
|
// Each direction half-closes its destination once its source reaches EOF, so a
|
||||||
|
// peer that waits for the request to end before answering still sees the EOF.
|
||||||
|
// Idleness is tracked across both directions rather than per direction: the
|
||||||
|
// client sends nothing for the length of a download, and half-closing its write
|
||||||
|
// side then would tell the origin the client had gone away.
|
||||||
|
func spliceTunnel(client, upstream net.Conn, closing <-chan struct{}, idleTimeout time.Duration) {
|
||||||
|
var lastActivity atomic.Int64
|
||||||
|
lastActivity.Store(time.Now().UnixNano())
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
go func() {
|
go func() {
|
||||||
defer splice.Done()
|
defer wg.Done()
|
||||||
_, _ = io.Copy(upstream, client)
|
copyTracking(upstream, client, &lastActivity, idleTimeout)
|
||||||
if cw, ok := upstream.(interface{ CloseWrite() error }); ok {
|
if cw, ok := upstream.(interface{ CloseWrite() error }); ok {
|
||||||
_ = cw.CloseWrite()
|
_ = cw.CloseWrite()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
go func() {
|
go func() {
|
||||||
defer splice.Done()
|
defer wg.Done()
|
||||||
_, _ = io.Copy(client, upstream)
|
copyTracking(client, upstream, &lastActivity, idleTimeout)
|
||||||
if cw, ok := client.(interface{ CloseWrite() error }); ok {
|
if cw, ok := client.(interface{ CloseWrite() error }); ok {
|
||||||
_ = cw.CloseWrite()
|
_ = cw.CloseWrite()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
splice.Wait()
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(idleTimeout / 4)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case <-closing:
|
||||||
|
case <-ticker.C:
|
||||||
|
if time.Since(time.Unix(0, lastActivity.Load())) < idleTimeout {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closing both ends unblocks whichever copy is still reading. The
|
||||||
|
// caller's own deferred Close calls then become no-ops.
|
||||||
|
_ = client.Close()
|
||||||
|
_ = upstream.Close()
|
||||||
|
<-done
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyTracking copies src into dst, recording the time of every chunk that
|
||||||
|
// moves so [spliceTunnel] can tell a busy tunnel from an idle one.
|
||||||
|
func copyTracking(dst, src net.Conn, lastActivity *atomic.Int64, writeTimeout time.Duration) {
|
||||||
|
buf := make([]byte, 32*1024)
|
||||||
|
|
||||||
|
for {
|
||||||
|
n, readErr := src.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
lastActivity.Store(time.Now().UnixNano())
|
||||||
|
|
||||||
|
// Bound the write. A destination that has gone away accepts the
|
||||||
|
// first chunk into its send buffer and only fails on the next one,
|
||||||
|
// so without a deadline this direction keeps a dead tunnel alive
|
||||||
|
// for one more chunk. A destination that stops reading altogether
|
||||||
|
// would block here forever.
|
||||||
|
_ = dst.SetWriteDeadline(time.Now().Add(writeTimeout))
|
||||||
|
|
||||||
|
_, writeErr := dst.Write(buf[:n])
|
||||||
|
if writeErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastActivity.Store(time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleForward handles plain HTTP requests sent to the proxy as absolute
|
// handleForward handles plain HTTP requests sent to the proxy as absolute
|
||||||
@@ -275,17 +470,35 @@ func (p *pinningProxy) handleForward(w http.ResponseWriter, req *http.Request) {
|
|||||||
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)
|
||||||
@@ -298,7 +511,7 @@ 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()
|
||||||
|
|
||||||
@@ -364,3 +577,11 @@ func isClientCancellation(ctx context.Context, err error) bool {
|
|||||||
}
|
}
|
||||||
return ctx.Err() != nil
|
return ctx.Err() != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dialThroughUpstreamProxy tunnels to target through the upstream proxy,
|
||||||
|
// letting [gotenberg.DialThroughProxy] perform the authenticated CONNECT that
|
||||||
|
// Chromium cannot. dialBypass dials the proxy itself and is overridable in
|
||||||
|
// tests. See https://github.com/gotenberg/gotenberg/issues/1592.
|
||||||
|
func (p *pinningProxy) dialThroughUpstreamProxy(ctx context.Context, proxyURL *url.URL, target string) (net.Conn, error) {
|
||||||
|
return gotenberg.DialThroughProxy(ctx, proxyURL, target, p.dialBypass)
|
||||||
|
}
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ func TestPinningProxy_Forward_Pinned_Success(t *testing.T) {
|
|||||||
upstreamURL := mustParseURL(t, upstream.URL)
|
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
|
||||||
@@ -151,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)
|
||||||
}
|
}
|
||||||
@@ -187,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
|
||||||
}
|
}
|
||||||
@@ -236,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
|
||||||
}
|
}
|
||||||
@@ -276,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
|
||||||
@@ -316,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
|
||||||
@@ -386,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)
|
||||||
}
|
}
|
||||||
@@ -445,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" {
|
||||||
@@ -488,7 +488,7 @@ func TestPinningProxy_DNSRebind_SingleResolution(t *testing.T) {
|
|||||||
// [TestPinningProxy_CONNECT_BlockedByDecide].
|
// [TestPinningProxy_CONNECT_BlockedByDecide].
|
||||||
func TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug(t *testing.T) {
|
func TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug(t *testing.T) {
|
||||||
rec := &recordingHandler{}
|
rec := &recordingHandler{}
|
||||||
p := newPinningProxy(nil, nil, false, false)
|
p := newPinningProxy(nil, nil, false, false, false)
|
||||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
// Mimic the wrap chain produced by outbound.resolveHost when the
|
// Mimic the wrap chain produced by outbound.resolveHost when the
|
||||||
// DNS lookup is canceled mid-flight by Chromium hanging up.
|
// DNS lookup is canceled mid-flight by Chromium hanging up.
|
||||||
@@ -547,7 +547,7 @@ func TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug(t *testing.T) {
|
|||||||
// HTTP forward requests aborted by the client must also log at debug.
|
// HTTP forward requests aborted by the client must also log at debug.
|
||||||
func TestPinningProxy_Forward_ClientCancellation_LoggedAtDebug(t *testing.T) {
|
func TestPinningProxy_Forward_ClientCancellation_LoggedAtDebug(t *testing.T) {
|
||||||
rec := &recordingHandler{}
|
rec := &recordingHandler{}
|
||||||
p := newPinningProxy(nil, nil, false, false)
|
p := newPinningProxy(nil, nil, false, false, false)
|
||||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
return gotenberg.OutboundDecision{}, fmt.Errorf("validate host: %w", context.DeadlineExceeded)
|
return gotenberg.OutboundDecision{}, fmt.Errorf("validate host: %w", context.DeadlineExceeded)
|
||||||
}
|
}
|
||||||
@@ -596,7 +596,7 @@ func TestPinningProxy_Forward_ClientCancellation_LoggedAtDebug(t *testing.T) {
|
|||||||
// still surface at warn level so operators see real refusals.
|
// still surface at warn level so operators see real refusals.
|
||||||
func TestPinningProxy_PolicyDenial_LoggedAtWarn(t *testing.T) {
|
func TestPinningProxy_PolicyDenial_LoggedAtWarn(t *testing.T) {
|
||||||
rec := &recordingHandler{}
|
rec := &recordingHandler{}
|
||||||
p := newPinningProxy(nil, nil, false, false)
|
p := newPinningProxy(nil, nil, false, false, false)
|
||||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
return gotenberg.OutboundDecision{}, fmt.Errorf("denied: %w", gotenberg.ErrFiltered)
|
return gotenberg.OutboundDecision{}, fmt.Errorf("denied: %w", gotenberg.ErrFiltered)
|
||||||
}
|
}
|
||||||
@@ -652,7 +652,7 @@ func TestPinningProxy_PolicyDenial_LoggedAtWarn(t *testing.T) {
|
|||||||
// [TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn].
|
// [TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn].
|
||||||
func TestPinningProxy_CONNECT_DialCancellation_LoggedAtDebug(t *testing.T) {
|
func TestPinningProxy_CONNECT_DialCancellation_LoggedAtDebug(t *testing.T) {
|
||||||
rec := &recordingHandler{}
|
rec := &recordingHandler{}
|
||||||
p := newPinningProxy(nil, nil, false, false)
|
p := newPinningProxy(nil, nil, false, false, false)
|
||||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
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
|
||||||
}
|
}
|
||||||
@@ -713,7 +713,7 @@ func TestPinningProxy_CONNECT_DialCancellation_LoggedAtDebug(t *testing.T) {
|
|||||||
// must still warn so operators see real problems.
|
// must still warn so operators see real problems.
|
||||||
func TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn(t *testing.T) {
|
func TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn(t *testing.T) {
|
||||||
rec := &recordingHandler{}
|
rec := &recordingHandler{}
|
||||||
p := newPinningProxy(nil, nil, false, false)
|
p := newPinningProxy(nil, nil, false, false, false)
|
||||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
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
|
||||||
}
|
}
|
||||||
@@ -770,7 +770,7 @@ func TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn(t *testing.T) {
|
|||||||
// logs at debug, not warn. Genuine RoundTrip failures still warn.
|
// logs at debug, not warn. Genuine RoundTrip failures still warn.
|
||||||
func TestPinningProxy_Forward_RoundTripCancellation_LoggedAtDebug(t *testing.T) {
|
func TestPinningProxy_Forward_RoundTripCancellation_LoggedAtDebug(t *testing.T) {
|
||||||
rec := &recordingHandler{}
|
rec := &recordingHandler{}
|
||||||
p := newPinningProxy(nil, nil, false, false)
|
p := newPinningProxy(nil, nil, false, false, false)
|
||||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
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
|
||||||
}
|
}
|
||||||
@@ -868,7 +868,7 @@ func TestIsClientCancellation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -882,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)
|
||||||
@@ -898,3 +898,280 @@ func TestPinningProxy_StopIdempotent(t *testing.T) {
|
|||||||
t.Fatalf("second Stop on stopped proxy: %v", err)
|
t.Fatalf("second Stop on stopped proxy: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tcpPair returns the two ends of a connected loopback TCP connection. Both
|
||||||
|
// ends are closed when the test finishes.
|
||||||
|
func tcpPair(t *testing.T) (net.Conn, net.Conn) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = listener.Close() }()
|
||||||
|
|
||||||
|
type accepted struct {
|
||||||
|
conn net.Conn
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
acceptChan := make(chan accepted, 1)
|
||||||
|
go func() {
|
||||||
|
conn, acceptErr := listener.Accept()
|
||||||
|
acceptChan <- accepted{conn: conn, err: acceptErr}
|
||||||
|
}()
|
||||||
|
|
||||||
|
dialed, err := net.Dial("tcp", listener.Addr().String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res := <-acceptChan
|
||||||
|
if res.err != nil {
|
||||||
|
t.Fatalf("accept: %v", res.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = dialed.Close()
|
||||||
|
_ = res.conn.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
return dialed, res.conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpliceTunnel_IdleTunnelIsClosed covers the leak where an upstream that
|
||||||
|
// accepted a CONNECT tunnel and then never spoke pinned both splice goroutines
|
||||||
|
// and both sockets for the lifetime of the process. A hijacked connection
|
||||||
|
// carries no deadline and net/http stops tracking it, so the idle bound in
|
||||||
|
// spliceTunnel is the only thing that ends such a tunnel.
|
||||||
|
func TestSpliceTunnel_IdleTunnelIsClosed(t *testing.T) {
|
||||||
|
// The peers are kept open by the pair's cleanup: the tunnel is silent, not
|
||||||
|
// finished.
|
||||||
|
client, _ := tcpPair(t)
|
||||||
|
upstream, _ := tcpPair(t)
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
spliceTunnel(client, upstream, nil, 100*time.Millisecond)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("spliceTunnel did not return on an idle tunnel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpliceTunnel_ClosingShutsTunnelDown verifies that stopping the proxy
|
||||||
|
// reaps in-flight tunnels. http.Server.Shutdown cannot: it stops tracking a
|
||||||
|
// connection once a handler hijacks it, so without this signal a tunnel would
|
||||||
|
// outlive the proxy and every Chromium restart after it.
|
||||||
|
func TestSpliceTunnel_ClosingShutsTunnelDown(t *testing.T) {
|
||||||
|
client, _ := tcpPair(t)
|
||||||
|
upstream, _ := tcpPair(t)
|
||||||
|
|
||||||
|
closing := make(chan struct{})
|
||||||
|
done := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// An idle timeout far beyond the test: only closing can end this.
|
||||||
|
spliceTunnel(client, upstream, closing, time.Hour)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
close(closing)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("spliceTunnel did not return when the proxy shut down")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpliceTunnel_ActiveTransferOutlivesIdleTimeout guards the idle bound
|
||||||
|
// against cutting a healthy transfer. Idleness is tracked across both
|
||||||
|
// directions, so a download that keeps making progress must survive well past
|
||||||
|
// the timeout even though the client sends nothing throughout.
|
||||||
|
func TestSpliceTunnel_ActiveTransferOutlivesIdleTimeout(t *testing.T) {
|
||||||
|
const (
|
||||||
|
idleTimeout = 100 * time.Millisecond
|
||||||
|
chunks = 10
|
||||||
|
interval = 30 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
client, clientPeer := tcpPair(t)
|
||||||
|
upstream, upstreamPeer := tcpPair(t)
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
spliceTunnel(client, upstream, nil, idleTimeout)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Trickle a response for well over the idle timeout, then finish.
|
||||||
|
go func() {
|
||||||
|
for range chunks {
|
||||||
|
_, _ = upstreamPeer.Write([]byte("x"))
|
||||||
|
time.Sleep(interval)
|
||||||
|
}
|
||||||
|
_ = upstreamPeer.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
received := 0
|
||||||
|
buf := make([]byte, chunks)
|
||||||
|
|
||||||
|
for received < chunks {
|
||||||
|
err := clientPeer.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("set read deadline: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, readErr := clientPeer.Read(buf)
|
||||||
|
received += n
|
||||||
|
if readErr != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if received != chunks {
|
||||||
|
t.Fatalf("received %d bytes, want %d: the tunnel was cut while still transferring", received, chunks)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("spliceTunnel did not return after the upstream closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPinningProxy_CONNECT_TunnelCeiling verifies the ceiling that bounds what
|
||||||
|
// tunnels refusing to end can accumulate. The idle bound cannot cover a peer
|
||||||
|
// that trickles just under it, so the count is what stops the growth.
|
||||||
|
func TestPinningProxy_CONNECT_TunnelCeiling(t *testing.T) {
|
||||||
|
// An upstream that accepts and then says nothing: the tunnel stays open.
|
||||||
|
upstreamAddr, stop := newRawTCPServer(t, func(c net.Conn) {
|
||||||
|
<-make(chan struct{})
|
||||||
|
})
|
||||||
|
t.Cleanup(stop)
|
||||||
|
|
||||||
|
p := newPinningProxy(nil, nil, false, false, false)
|
||||||
|
p.maxTunnels = 1
|
||||||
|
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, network string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||||
|
return net.Dial(network, upstreamAddr)
|
||||||
|
}
|
||||||
|
proxyURL := newProxyForTest(t, p)
|
||||||
|
proxyAddr := strings.TrimPrefix(proxyURL, "http://")
|
||||||
|
|
||||||
|
connect := func(t *testing.T) *bufio.Reader {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
conn, err := net.Dial("tcp", proxyAddr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial proxy: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
|
||||||
|
err = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("set deadline: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = fmt.Fprintf(conn, "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write CONNECT: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bufio.NewReader(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
first := connect(t)
|
||||||
|
statusLine, err := first.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read first status: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(statusLine, " 200 ") {
|
||||||
|
t.Fatalf("first CONNECT status = %q, want 200", statusLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first tunnel now holds the only slot.
|
||||||
|
second := connect(t)
|
||||||
|
resp, err := http.ReadResponse(second, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read second response: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("second CONNECT status = %d, want %d", resp.StatusCode, http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := p.tunnels.Load(); got != 1 {
|
||||||
|
t.Errorf("tunnels in flight = %d, want 1: a refused CONNECT must not consume a slot", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPinningProxy_TunnelSlotIsReleased verifies a completed tunnel gives its
|
||||||
|
// slot back, so the ceiling bounds concurrency rather than lifetime totals.
|
||||||
|
func TestPinningProxy_TunnelSlotIsReleased(t *testing.T) {
|
||||||
|
upstreamAddr, stop := newRawTCPServer(t, func(c net.Conn) {
|
||||||
|
defer c.Close()
|
||||||
|
_, _ = c.Write([]byte("HI"))
|
||||||
|
})
|
||||||
|
t.Cleanup(stop)
|
||||||
|
|
||||||
|
p := newPinningProxy(nil, nil, false, false, false)
|
||||||
|
p.maxTunnels = 1
|
||||||
|
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, network string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||||
|
return net.Dial(network, upstreamAddr)
|
||||||
|
}
|
||||||
|
proxyURL := newProxyForTest(t, p)
|
||||||
|
proxyAddr := strings.TrimPrefix(proxyURL, "http://")
|
||||||
|
|
||||||
|
for attempt := range 3 {
|
||||||
|
conn, err := net.Dial("tcp", proxyAddr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("attempt %d dial proxy: %v", attempt, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("attempt %d set deadline: %v", attempt, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = fmt.Fprintf(conn, "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("attempt %d write CONNECT: %v", attempt, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
br := bufio.NewReader(conn)
|
||||||
|
statusLine, err := br.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("attempt %d read status: %v", attempt, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(statusLine, " 200 ") {
|
||||||
|
t.Fatalf("attempt %d CONNECT status = %q, want 200: the slot was not released", attempt, statusLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain until the upstream's close ends the tunnel, then release it.
|
||||||
|
_, _ = io.ReadAll(br)
|
||||||
|
_ = conn.Close()
|
||||||
|
|
||||||
|
// The handler returns just after the splice ends.
|
||||||
|
for range 100 {
|
||||||
|
if p.tunnels.Load() == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if got := p.tunnels.Load(); got != 0 {
|
||||||
|
t.Fatalf("attempt %d: tunnels in flight = %d, want 0", attempt, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,20 @@ import (
|
|||||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Bounds on the scoped extra HTTP headers feature. Chromium matches every
|
||||||
|
// scoped header against every paused sub-resource request, so the total
|
||||||
|
// matching work is the product of the header count and the sub-resource count.
|
||||||
|
// These caps bound the factors the client controls; [scopeMatchBudget] bounds
|
||||||
|
// the product. See https://github.com/gotenberg/gotenberg/issues/1588.
|
||||||
|
const (
|
||||||
|
maxExtraHttpHeaders = 64
|
||||||
|
maxExtraHttpHeaderScopeLength = 1024
|
||||||
|
|
||||||
|
// A scope pattern matches against a URL, which takes microseconds for any
|
||||||
|
// reasonable pattern.
|
||||||
|
extraHttpHeaderScopeMatchTimeout = 250 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
var sameSiteRegexp = regexp2.MustCompile(
|
var sameSiteRegexp = regexp2.MustCompile(
|
||||||
`("sameSite"\s*:\s*")(?i:(lax|strict|none))(")`,
|
`("sameSite"\s*:\s*")(?i:(lax|strict|none))(")`,
|
||||||
regexp2.None,
|
regexp2.None,
|
||||||
@@ -169,6 +183,10 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
|||||||
return fmt.Errorf("unmarshal extraHttpHeaders: %w", err)
|
return fmt.Errorf("unmarshal extraHttpHeaders: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(headers) > maxExtraHttpHeaders {
|
||||||
|
return fmt.Errorf("too many headers, got %d, expected at most %d", len(headers), maxExtraHttpHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
var scope string
|
var scope string
|
||||||
var valueTokens []string
|
var valueTokens []string
|
||||||
@@ -198,12 +216,17 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
|||||||
|
|
||||||
var scopeRegexp *regexp2.Regexp
|
var scopeRegexp *regexp2.Regexp
|
||||||
if len(scope) > 0 {
|
if len(scope) > 0 {
|
||||||
|
if len(scope) > maxExtraHttpHeaderScopeLength {
|
||||||
|
err = errors.Join(err, fmt.Errorf("scope regex pattern for header '%s' is too long, got %d characters, expected at most %d", k, len(scope), maxExtraHttpHeaderScopeLength))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
p, errCompile := regexp2.Compile(scope, regexp2.None)
|
p, errCompile := regexp2.Compile(scope, regexp2.None)
|
||||||
if errCompile != nil {
|
if errCompile != nil {
|
||||||
err = errors.Join(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile))
|
err = errors.Join(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
p.MatchTimeout = 5 * time.Second
|
p.MatchTimeout = extraHttpHeaderScopeMatchTimeout
|
||||||
scopeRegexp = p
|
scopeRegexp = p
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,6 +366,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
|||||||
var (
|
var (
|
||||||
width, height int
|
width, height int
|
||||||
clip bool
|
clip bool
|
||||||
|
selector string
|
||||||
format string
|
format string
|
||||||
quality int
|
quality int
|
||||||
optimizeForSpeed bool
|
optimizeForSpeed bool
|
||||||
@@ -353,6 +377,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
|||||||
Int("width", &width, defaultScreenshotOptions.Width).
|
Int("width", &width, defaultScreenshotOptions.Width).
|
||||||
Int("height", &height, defaultScreenshotOptions.Height).
|
Int("height", &height, defaultScreenshotOptions.Height).
|
||||||
Bool("clip", &clip, defaultScreenshotOptions.Clip).
|
Bool("clip", &clip, defaultScreenshotOptions.Clip).
|
||||||
|
String("selector", &selector, defaultScreenshotOptions.Selector).
|
||||||
Custom("format", func(value string) error {
|
Custom("format", func(value string) error {
|
||||||
if value == "" {
|
if value == "" {
|
||||||
format = defaultScreenshotOptions.Format
|
format = defaultScreenshotOptions.Format
|
||||||
@@ -397,6 +422,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
|||||||
Width: width,
|
Width: width,
|
||||||
Height: height,
|
Height: height,
|
||||||
Clip: clip,
|
Clip: clip,
|
||||||
|
Selector: selector,
|
||||||
Format: format,
|
Format: format,
|
||||||
Quality: quality,
|
Quality: quality,
|
||||||
OptimizeForSpeed: optimizeForSpeed,
|
OptimizeForSpeed: optimizeForSpeed,
|
||||||
@@ -446,14 +472,22 @@ 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)
|
watermarks, wErr := pdfengines.FormDataPdfWatermarks(form)
|
||||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
if wErr != nil {
|
||||||
stamp := pdfengines.FormDataPdfStamp(form, false)
|
return fmt.Errorf("form data watermarks: %w", wErr)
|
||||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
}
|
||||||
|
stamps, sErr := pdfengines.FormDataPdfStamps(form)
|
||||||
|
if sErr != nil {
|
||||||
|
return fmt.Errorf("form data stamps: %w", sErr)
|
||||||
|
}
|
||||||
|
var watermarkFiles, stampFiles []string
|
||||||
|
form.Watermarks(&watermarkFiles).Stamps(&stampFiles)
|
||||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
|
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||||
|
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||||
|
|
||||||
var url string
|
var url string
|
||||||
err := form.
|
err := form.
|
||||||
@@ -468,16 +502,16 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("reject URL scheme: %w", err)
|
return fmt.Errorf("reject URL scheme: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
|
err = pdfengines.BindWatermarkFiles(watermarks, watermarkFiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate watermark: %w", err)
|
return fmt.Errorf("bind watermark files: %w", err)
|
||||||
}
|
}
|
||||||
err = pdfengines.EnsureStampFile(&stamp, stampFile)
|
err = pdfengines.BindStampFiles(stamps, stampFiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate stamp: %w", err)
|
return fmt.Errorf("bind stamp files: %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, watermarks, stamps, rotateAngle, rotatePages, optimizeImages, imageQuality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert URL to PDF: %w", err)
|
return fmt.Errorf("convert URL to PDF: %w", err)
|
||||||
}
|
}
|
||||||
@@ -534,14 +568,22 @@ 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)
|
watermarks, wErr := pdfengines.FormDataPdfWatermarks(form)
|
||||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
if wErr != nil {
|
||||||
stamp := pdfengines.FormDataPdfStamp(form, false)
|
return fmt.Errorf("form data watermarks: %w", wErr)
|
||||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
}
|
||||||
|
stamps, sErr := pdfengines.FormDataPdfStamps(form)
|
||||||
|
if sErr != nil {
|
||||||
|
return fmt.Errorf("form data stamps: %w", sErr)
|
||||||
|
}
|
||||||
|
var watermarkFiles, stampFiles []string
|
||||||
|
form.Watermarks(&watermarkFiles).Stamps(&stampFiles)
|
||||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
|
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||||
|
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||||
|
|
||||||
var inputPath string
|
var inputPath string
|
||||||
err := form.
|
err := form.
|
||||||
@@ -551,18 +593,18 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("validate form data: %w", err)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
|
err = pdfengines.BindWatermarkFiles(watermarks, watermarkFiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate watermark: %w", err)
|
return fmt.Errorf("bind watermark files: %w", err)
|
||||||
}
|
}
|
||||||
err = pdfengines.EnsureStampFile(&stamp, stampFile)
|
err = pdfengines.BindStampFiles(stamps, stampFiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate stamp: %w", err)
|
return fmt.Errorf("bind stamp files: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
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, watermarks, stamps, rotateAngle, rotatePages, optimizeImages, imageQuality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert HTML to PDF: %w", err)
|
return fmt.Errorf("convert HTML to PDF: %w", err)
|
||||||
}
|
}
|
||||||
@@ -616,14 +658,22 @@ 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)
|
watermarks, wErr := pdfengines.FormDataPdfWatermarks(form)
|
||||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
if wErr != nil {
|
||||||
stamp := pdfengines.FormDataPdfStamp(form, false)
|
return fmt.Errorf("form data watermarks: %w", wErr)
|
||||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
}
|
||||||
|
stamps, sErr := pdfengines.FormDataPdfStamps(form)
|
||||||
|
if sErr != nil {
|
||||||
|
return fmt.Errorf("form data stamps: %w", sErr)
|
||||||
|
}
|
||||||
|
var watermarkFiles, stampFiles []string
|
||||||
|
form.Watermarks(&watermarkFiles).Stamps(&stampFiles)
|
||||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
|
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||||
|
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
inputPath string
|
inputPath string
|
||||||
@@ -638,13 +688,13 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("validate form data: %w", err)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
|
err = pdfengines.BindWatermarkFiles(watermarks, watermarkFiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate watermark: %w", err)
|
return fmt.Errorf("bind watermark files: %w", err)
|
||||||
}
|
}
|
||||||
err = pdfengines.EnsureStampFile(&stamp, stampFile)
|
err = pdfengines.BindStampFiles(stamps, stampFiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate stamp: %w", err)
|
return fmt.Errorf("bind stamp files: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
url, err := markdownToHtml(ctx, inputPath, markdownPaths)
|
url, err := markdownToHtml(ctx, inputPath, markdownPaths)
|
||||||
@@ -653,7 +703,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, watermarks, stamps, rotateAngle, rotatePages, optimizeImages, imageQuality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert markdown to PDF: %w", err)
|
return fmt.Errorf("convert markdown to PDF: %w", err)
|
||||||
}
|
}
|
||||||
@@ -778,7 +828,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, watermarks, stamps []gotenberg.Stamp, rotateAngle int, rotatePages string, optimizeImages bool, imageQuality int) error {
|
||||||
outputPath := ctx.GeneratePath(".pdf")
|
outputPath := ctx.GeneratePath(".pdf")
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/1130.
|
// See https://github.com/gotenberg/gotenberg/issues/1130.
|
||||||
filename := ctx.OutputFilename(outputPath)
|
filename := ctx.OutputFilename(outputPath)
|
||||||
@@ -840,7 +890,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
|
||||||
}
|
}
|
||||||
@@ -850,12 +910,12 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
|||||||
return fmt.Errorf("split PDF: %w", err)
|
return fmt.Errorf("split PDF: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.WatermarkStub(ctx, engine, watermark, outputPaths)
|
err = pdfengines.WatermarkStub(ctx, engine, watermarks, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("watermark PDFs: %w", err)
|
return fmt.Errorf("watermark PDFs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.StampStub(ctx, engine, stamp, outputPaths)
|
err = pdfengines.StampStub(ctx, engine, stamps, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("stamp PDFs: %w", err)
|
return fmt.Errorf("stamp PDFs: %w", err)
|
||||||
}
|
}
|
||||||
@@ -865,6 +925,13 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = pdfengines.OptimizeStub(ctx, engine, optimizeImages, imageQuality, outputPaths)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("optimize PDF images: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pdfFormats = pdfengines.FacturXPdfFormats(ctx, engine, facturX, pdfFormats, true, nil)
|
||||||
|
|
||||||
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert PDF(s): %w", err)
|
return fmt.Errorf("convert PDF(s): %w", err)
|
||||||
@@ -887,7 +954,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)
|
||||||
}
|
}
|
||||||
@@ -920,7 +992,17 @@ func screenshotUrl(ctx *api.Context, chromium Api, url string, options Screensho
|
|||||||
outputPath := ctx.GeneratePath(ext)
|
outputPath := ctx.GeneratePath(ext)
|
||||||
|
|
||||||
err := chromium.Screenshot(ctx, ctx.Log(), url, outputPath, options)
|
err := chromium.Screenshot(ctx, ctx.Log(), url, outputPath, options)
|
||||||
err = handleChromiumError(err, options.Options)
|
if errors.Is(err, ErrScreenshotSelectorNotFound) {
|
||||||
|
err = api.WrapError(
|
||||||
|
err,
|
||||||
|
api.NewSentinelHttpError(
|
||||||
|
http.StatusBadRequest,
|
||||||
|
fmt.Sprintf("The selector '%s' (selector) matched no element with a visible box", options.Selector),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
err = handleChromiumError(err, options.Options)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("screenshot: %w", err)
|
return fmt.Errorf("screenshot: %w", err)
|
||||||
}
|
}
|
||||||
@@ -938,6 +1020,16 @@ func handleChromiumError(err error, options Options) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, ErrChromiumCrashed) {
|
||||||
|
return api.WrapError(
|
||||||
|
err,
|
||||||
|
api.NewSentinelHttpError(
|
||||||
|
http.StatusServiceUnavailable,
|
||||||
|
"Chromium crashed while processing the request. Retry, or reduce the workload if the problem persists.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if errors.Is(err, ErrInvalidEvaluationExpression) {
|
if errors.Is(err, ErrInvalidEvaluationExpression) {
|
||||||
if options.WaitForExpression == "" {
|
if options.WaitForExpression == "" {
|
||||||
// We do not expect the 'waitWindowStatus' form field to return
|
// We do not expect the 'waitWindowStatus' form field to return
|
||||||
|
|||||||
51
pkg/modules/chromium/routes_test.go
Normal file
51
pkg/modules/chromium/routes_test.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package chromium
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestHandleChromiumError_Crashed pins the mapping of a Chromium renderer
|
||||||
|
// crash to a 503 Service Unavailable. When the renderer crashes mid-conversion,
|
||||||
|
// the request must fail fast with 503 rather than hang until the deadline and
|
||||||
|
// surface as a generic timeout.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1640.
|
||||||
|
func TestHandleChromiumError_Crashed(t *testing.T) {
|
||||||
|
// Mirror the wrapping done by [chromiumBrowser.do].
|
||||||
|
err := handleChromiumError(fmt.Errorf("handle tasks: %w", ErrChromiumCrashed), Options{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error, got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
status, message := api.ParseError(err)
|
||||||
|
if status != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("status = %d, want %d (message: %s)", status, http.StatusServiceUnavailable, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := "Chromium crashed while processing the request. Retry, or reduce the workload if the problem persists."
|
||||||
|
if message != want {
|
||||||
|
t.Errorf("message = %q, want %q", message, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleChromiumError_CrashedTakesPrecedence guards the ordering in
|
||||||
|
// [handleChromiumError]: a crash is a server-side failure and must map to 503
|
||||||
|
// even when the error chain also carries a marker that another branch would
|
||||||
|
// map to a client-error status.
|
||||||
|
func TestHandleChromiumError_CrashedTakesPrecedence(t *testing.T) {
|
||||||
|
err := handleChromiumError(
|
||||||
|
fmt.Errorf("handle tasks: %w; %w", ErrChromiumCrashed, ErrInvalidHttpStatusCode),
|
||||||
|
Options{},
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error, got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
status, _ := api.ParseError(err)
|
||||||
|
if status != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("status = %d, want %d", status, http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
52
pkg/modules/chromium/scopebudget.go
Normal file
52
pkg/modules/chromium/scopebudget.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package chromium
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// scopeMatchBudgetPerConversion caps the total time a single conversion may
|
||||||
|
// spend matching scoped extra HTTP header patterns.
|
||||||
|
//
|
||||||
|
// The per-pattern MatchTimeout bounds one match, not their number: Chromium
|
||||||
|
// pauses every sub-resource request, and each paused request is matched against
|
||||||
|
// every scoped header. Without a shared budget the total is the product of the
|
||||||
|
// two, both of which the client controls.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1588.
|
||||||
|
const scopeMatchBudgetPerConversion = 5 * time.Second
|
||||||
|
|
||||||
|
// scopeMatchBudget is a time allowance shared by every scope match of a
|
||||||
|
// conversion. It is safe for concurrent use: paused requests are handled on
|
||||||
|
// their own goroutines.
|
||||||
|
type scopeMatchBudget struct {
|
||||||
|
remaining atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// newScopeMatchBudget returns a [scopeMatchBudget] allowing d of matching.
|
||||||
|
func newScopeMatchBudget(d time.Duration) *scopeMatchBudget {
|
||||||
|
b := new(scopeMatchBudget)
|
||||||
|
b.remaining.Store(int64(d))
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryAcquire reports whether the budget still allows a match.
|
||||||
|
func (b *scopeMatchBudget) tryAcquire() bool {
|
||||||
|
return b.remaining.Load() > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// consume subtracts the time a match took. It saturates at zero so that a long
|
||||||
|
// match cannot wrap the counter back into credit.
|
||||||
|
func (b *scopeMatchBudget) consume(d time.Duration) {
|
||||||
|
for {
|
||||||
|
current := b.remaining.Load()
|
||||||
|
if current <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next := max(current-int64(d), 0)
|
||||||
|
|
||||||
|
if b.remaining.CompareAndSwap(current, next) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
122
pkg/modules/chromium/scopebudget_test.go
Normal file
122
pkg/modules/chromium/scopebudget_test.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package chromium
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dlclark/regexp2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScopeMatchBudget(t *testing.T) {
|
||||||
|
t.Run("allows matching while credit remains", func(t *testing.T) {
|
||||||
|
b := newScopeMatchBudget(time.Second)
|
||||||
|
if !b.tryAcquire() {
|
||||||
|
t.Fatal("tryAcquire() = false on a fresh budget, want true")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("denies matching once exhausted", func(t *testing.T) {
|
||||||
|
b := newScopeMatchBudget(time.Second)
|
||||||
|
b.consume(time.Second)
|
||||||
|
if b.tryAcquire() {
|
||||||
|
t.Error("tryAcquire() = true after the budget was spent, want false")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("saturates at zero instead of wrapping into credit", func(t *testing.T) {
|
||||||
|
b := newScopeMatchBudget(time.Second)
|
||||||
|
b.consume(time.Hour)
|
||||||
|
if got := b.remaining.Load(); got != 0 {
|
||||||
|
t.Errorf("remaining = %d, want 0", got)
|
||||||
|
}
|
||||||
|
if b.tryAcquire() {
|
||||||
|
t.Error("tryAcquire() = true after an overlong match, want false")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a spent budget stays spent", func(t *testing.T) {
|
||||||
|
b := newScopeMatchBudget(time.Second)
|
||||||
|
b.consume(time.Second)
|
||||||
|
b.consume(time.Millisecond)
|
||||||
|
if got := b.remaining.Load(); got != 0 {
|
||||||
|
t.Errorf("remaining = %d, want 0", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("is safe for concurrent use", func(t *testing.T) {
|
||||||
|
const goroutines = 64
|
||||||
|
// Each goroutine spends 1ms against a budget of half that many
|
||||||
|
// milliseconds, so the total spend overshoots it.
|
||||||
|
b := newScopeMatchBudget(time.Duration(goroutines/2) * time.Millisecond)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range goroutines {
|
||||||
|
wg.Go(func() {
|
||||||
|
b.tryAcquire()
|
||||||
|
b.consume(time.Millisecond)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if got := b.remaining.Load(); got != 0 {
|
||||||
|
t.Errorf("remaining = %d, want 0", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScopeMatchBudget_BoundsCatastrophicBacktracking is the regression test for
|
||||||
|
// the amplification: many scoped headers matched against a hostile URL must cost
|
||||||
|
// the budget, not a multiple of it.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1588.
|
||||||
|
func TestScopeMatchBudget_BoundsCatastrophicBacktracking(t *testing.T) {
|
||||||
|
const (
|
||||||
|
headers = 16
|
||||||
|
budget = 200 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// Nested quantifier with no possible match: classic catastrophic
|
||||||
|
// backtracking.
|
||||||
|
pattern := compileScopePattern(t, `(a+)+b`)
|
||||||
|
url := "http://example.com/" + strings.Repeat("a", 40)
|
||||||
|
|
||||||
|
b := newScopeMatchBudget(budget)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
var matched int
|
||||||
|
for range headers {
|
||||||
|
if !b.tryAcquire() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
matchStart := time.Now()
|
||||||
|
_, _ = pattern.MatchString(url)
|
||||||
|
b.consume(time.Since(matchStart))
|
||||||
|
matched++
|
||||||
|
}
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if matched == headers {
|
||||||
|
t.Errorf("all %d headers were matched, want the budget to stop matching early", headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each match is separately capped at extraHttpHeaderScopeMatchTimeout, so
|
||||||
|
// the worst case is the budget plus one final match that started with the
|
||||||
|
// last of the credit. Generous slack keeps this stable on a loaded CI box.
|
||||||
|
ceiling := budget + extraHttpHeaderScopeMatchTimeout + time.Second
|
||||||
|
if elapsed > ceiling {
|
||||||
|
t.Errorf("matching took %s, want at most %s", elapsed, ceiling)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func compileScopePattern(t *testing.T, pattern string) *regexp2.Regexp {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
p, err := regexp2.Compile(pattern, regexp2.None)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("compile %q: %v", pattern, err)
|
||||||
|
}
|
||||||
|
p.MatchTimeout = extraHttpHeaderScopeMatchTimeout
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
@@ -6,112 +6,193 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/chromedp/cdproto/cdp"
|
"github.com/chromedp/cdproto/cdp"
|
||||||
"github.com/chromedp/cdproto/emulation"
|
"github.com/chromedp/cdproto/emulation"
|
||||||
"github.com/chromedp/cdproto/network"
|
"github.com/chromedp/cdproto/network"
|
||||||
"github.com/chromedp/cdproto/page"
|
"github.com/chromedp/cdproto/page"
|
||||||
|
"github.com/chromedp/cdproto/runtime"
|
||||||
|
"github.com/chromedp/cdproto/storage"
|
||||||
"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")
|
paperWidth := options.PaperWidth
|
||||||
|
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.
|
||||||
|
if options.Landscape {
|
||||||
|
// Landscape swaps the paper dimensions, so the page is
|
||||||
|
// WithPaperHeight wide by WithPaperWidth tall. Size both to
|
||||||
|
// the content so the width expands to fit a wide document
|
||||||
|
// (e.g. a table) instead of the height-only expansion
|
||||||
|
// landing on the width axis and truncating it.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1390.
|
||||||
|
paperWidth = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
|
||||||
|
paperHeight = (cssContentSize.Width / 96) + options.MarginLeft + options.MarginRight
|
||||||
|
} else {
|
||||||
|
// 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(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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +204,16 @@ func captureScreenshotActionFunc(logger *slog.Logger, outputPath string, options
|
|||||||
WithOptimizeForSpeed(options.OptimizeForSpeed).
|
WithOptimizeForSpeed(options.OptimizeForSpeed).
|
||||||
WithFormat(page.CaptureScreenshotFormat(options.Format))
|
WithFormat(page.CaptureScreenshotFormat(options.Format))
|
||||||
|
|
||||||
if options.Clip {
|
switch {
|
||||||
|
case options.Selector != "":
|
||||||
|
clip, err := elementClip(ctx, options.Selector)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugContext(ctx, fmt.Sprintf("clip screenshot to selector '%s'", options.Selector))
|
||||||
|
captureScreenshot = captureScreenshot.WithClip(clip)
|
||||||
|
case options.Clip:
|
||||||
captureScreenshot = captureScreenshot.WithClip(&page.Viewport{
|
captureScreenshot = captureScreenshot.WithClip(&page.Viewport{
|
||||||
Width: float64(options.Width),
|
Width: float64(options.Width),
|
||||||
Height: float64(options.Height),
|
Height: float64(options.Height),
|
||||||
@@ -164,6 +254,49 @@ func captureScreenshotActionFunc(logger *slog.Logger, outputPath string, options
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// elementClip resolves the first element matching selector to a page-space clip
|
||||||
|
// rectangle for Page.captureScreenshot.
|
||||||
|
//
|
||||||
|
// getBoundingClientRect reports viewport-relative CSS pixels; adding the scroll
|
||||||
|
// offset puts the rectangle in the document coordinate space that
|
||||||
|
// WithCaptureBeyondViewport expects. It fails with
|
||||||
|
// [ErrScreenshotSelectorNotFound] when nothing matches or the match has no
|
||||||
|
// rendered box (display:none or a zero area), so the caller can answer 400.
|
||||||
|
func elementClip(ctx context.Context, selector string) (*page.Viewport, error) {
|
||||||
|
var rect struct {
|
||||||
|
Found bool `json:"found"`
|
||||||
|
X float64 `json:"x"`
|
||||||
|
Y float64 `json:"y"`
|
||||||
|
Width float64 `json:"width"`
|
||||||
|
Height float64 `json:"height"`
|
||||||
|
}
|
||||||
|
|
||||||
|
expr := fmt.Sprintf(`(() => {
|
||||||
|
const el = document.querySelector(%s);
|
||||||
|
if (!el) {
|
||||||
|
return { found: false };
|
||||||
|
}
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return { found: true, x: r.left + window.scrollX, y: r.top + window.scrollY, width: r.width, height: r.height };
|
||||||
|
})()`, strconv.Quote(selector))
|
||||||
|
|
||||||
|
err := chromedp.Evaluate(expr, &rect).Do(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("evaluate selector box: %v: %w", err, ErrScreenshotSelectorNotFound)
|
||||||
|
}
|
||||||
|
if !rect.Found || rect.Width <= 0 || rect.Height <= 0 {
|
||||||
|
return nil, fmt.Errorf("selector %q matched no element with a visible box: %w", selector, ErrScreenshotSelectorNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &page.Viewport{
|
||||||
|
X: rect.X,
|
||||||
|
Y: rect.Y,
|
||||||
|
Width: rect.Width,
|
||||||
|
Height: rect.Height,
|
||||||
|
Scale: 1,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func setDeviceMetricsOverride(logger *slog.Logger, width, height int, deviceScaleFactor float64) 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")
|
||||||
@@ -215,6 +348,54 @@ func clearCookiesActionFunc(logger *slog.Logger, clear bool) chromedp.ActionFunc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clearStorageActionFunc clears the converted origin's local storage before the
|
||||||
|
// page loads, so state written by a previous conversion of the same origin does
|
||||||
|
// not leak into this one. See https://github.com/gotenberg/gotenberg/issues/919.
|
||||||
|
//
|
||||||
|
// Session storage is not touched: each conversion runs in its own browsing
|
||||||
|
// context (a fresh tab), so it is already isolated and cannot leak. Local
|
||||||
|
// storage is per-origin and shared across tabs of the long-lived browser, so it
|
||||||
|
// is the only web storage that carries over.
|
||||||
|
func clearStorageActionFunc(logger *slog.Logger, clear bool, rawURL string) chromedp.ActionFunc {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if !clear {
|
||||||
|
logger.DebugContext(ctx, "local storage not cleared")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
origin, ok := httpOrigin(rawURL)
|
||||||
|
if !ok {
|
||||||
|
// A file:// upload gets an opaque, per-request origin that is not
|
||||||
|
// shared between conversions, so there is nothing to clear.
|
||||||
|
logger.DebugContext(ctx, "local storage not cleared: non-http(s) origin is already isolated")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugContext(ctx, fmt.Sprintf("clear local storage for %s", origin))
|
||||||
|
|
||||||
|
err := storage.ClearDataForOrigin(origin, string(storage.TypeLocalStorage)).Do(ctx)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("clear local storage: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpOrigin returns the http(s) security origin (scheme://host[:port]) of
|
||||||
|
// rawURL, and false when rawURL is not http(s). A non-http(s) URL such as a
|
||||||
|
// file:// upload has an opaque origin that no other conversion shares.
|
||||||
|
func httpOrigin(rawURL string) (string, bool) {
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host), true
|
||||||
|
}
|
||||||
|
|
||||||
func disableJavaScriptActionFunc(logger *slog.Logger, disable bool) chromedp.ActionFunc {
|
func disableJavaScriptActionFunc(logger *slog.Logger, disable bool) chromedp.ActionFunc {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/175.
|
// See https://github.com/gotenberg/gotenberg/issues/175.
|
||||||
@@ -528,7 +709,13 @@ func waitForExpressionBeforePrintActionFunc(logger *slog.Logger, disableJavaScri
|
|||||||
return fmt.Errorf("context done while evaluating '%s': %w", expression, ctx.Err())
|
return fmt.Errorf("context done while evaluating '%s': %w", expression, ctx.Err())
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
var ok bool
|
var ok bool
|
||||||
evaluate := chromedp.Evaluate(expression, &ok)
|
// Await the result so a thenable expression (an async function
|
||||||
|
// returning a Promise) resolves before its value is read. A
|
||||||
|
// non-promise result is unaffected.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/pull/1617.
|
||||||
|
evaluate := chromedp.Evaluate(expression, &ok, func(p *runtime.EvaluateParams) *runtime.EvaluateParams {
|
||||||
|
return p.WithAwaitPromise(true)
|
||||||
|
})
|
||||||
|
|
||||||
err := evaluate.Do(ctx)
|
err := evaluate.Do(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
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"
|
||||||
@@ -78,6 +80,37 @@ var dangerousTags = []string{
|
|||||||
"FilePermissions", // Writing this changes the file's permissions
|
"FilePermissions", // Writing this changes the file's permissions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// controlOptions lists ExifTool command-line option names that collide with a
|
||||||
|
// tag assignment. A metadata key of "csv" becomes the argv entry "-csv=value",
|
||||||
|
// which exiftool reads as its own option rather than as a tag, so the value
|
||||||
|
// becomes a filename exiftool opens. Only an unprefixed key can collide:
|
||||||
|
// "-XMP:csv=value" is unambiguously a tag.
|
||||||
|
//
|
||||||
|
// See https://exiftool.org/exiftool_pod.html.
|
||||||
|
var controlOptions = []string{
|
||||||
|
"api", "argfile", "charset", "common_args", "config", "csv", "diff",
|
||||||
|
"echo", "efile", "execute", "ext", "fileorder", "geotag", "geosync",
|
||||||
|
"htmldump", "if", "json", "lang", "listitem", "o", "out", "p", "php",
|
||||||
|
"require", "srcfile", "stay_open", "tagsfromfile", "textout", "use", "w",
|
||||||
|
"wm", "xmlformat",
|
||||||
|
}
|
||||||
|
|
||||||
|
// isControlOption reports whether an unprefixed key would reach exiftool as
|
||||||
|
// one of its own options instead of as a tag assignment.
|
||||||
|
func isControlOption(key string) bool {
|
||||||
|
if strings.Contains(key, ":") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, option := range controlOptions {
|
||||||
|
if strings.EqualFold(key, option) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// isDangerousTag reports whether key matches one of the [dangerousTags]
|
// isDangerousTag reports whether key matches one of the [dangerousTags]
|
||||||
// after case-insensitive comparison with any group prefix stripped.
|
// after case-insensitive comparison with any group prefix stripped.
|
||||||
func isDangerousTag(key string) bool {
|
func isDangerousTag(key string) bool {
|
||||||
@@ -112,19 +145,33 @@ func buildExifToolWriteArgs(metadata map[string]any) ([]string, error) {
|
|||||||
if !safeKeyPattern.MatchString(key) {
|
if !safeKeyPattern.MatchString(key) {
|
||||||
return nil, fmt.Errorf("write PDF metadata with ExifTool: invalid metadata key %q: %w", key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
return nil, fmt.Errorf("write PDF metadata with ExifTool: invalid metadata key %q: %w", key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
||||||
}
|
}
|
||||||
|
if isControlOption(key) {
|
||||||
|
return nil, fmt.Errorf("write PDF metadata with ExifTool: metadata key %q is an ExifTool option, prefix it with a group such as %q: %w", key, "XMP:"+key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
tag := key
|
||||||
|
if key == "Trapped" {
|
||||||
|
// ExifTool writes the document info /Trapped entry as a malformed
|
||||||
|
// name-in-a-string, e.g. "(/Unknown)". pdfcpu's stricter validation
|
||||||
|
// (as of v0.15) rejects it, which breaks later pdfcpu operations on
|
||||||
|
// the file such as embedding. Writing Trapped to XMP keeps the value
|
||||||
|
// readable without the invalid document info entry.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1628.
|
||||||
|
tag = "XMP-pdf:Trapped"
|
||||||
|
}
|
||||||
|
|
||||||
switch val := value.(type) {
|
switch val := value.(type) {
|
||||||
case string:
|
case string:
|
||||||
if err := validateMetadataValue(key, val); err != nil {
|
if err := validateMetadataValue(key, val); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
args = append(args, fmt.Sprintf("-%s=%s", key, val))
|
args = append(args, fmt.Sprintf("-%s=%s", tag, val))
|
||||||
case []string:
|
case []string:
|
||||||
for _, s := range val {
|
for _, s := range val {
|
||||||
if err := validateMetadataValue(key, s); err != nil {
|
if err := validateMetadataValue(key, s); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
args = append(args, fmt.Sprintf("-%s=%s", key, s))
|
args = append(args, fmt.Sprintf("-%s=%s", tag, s))
|
||||||
}
|
}
|
||||||
case []any:
|
case []any:
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/1048.
|
// See https://github.com/gotenberg/gotenberg/issues/1048.
|
||||||
@@ -136,18 +183,18 @@ func buildExifToolWriteArgs(metadata map[string]any) ([]string, error) {
|
|||||||
if err := validateMetadataValue(key, s); err != nil {
|
if err := validateMetadataValue(key, s); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
args = append(args, fmt.Sprintf("-%s=%s", key, s))
|
args = append(args, fmt.Sprintf("-%s=%s", tag, s))
|
||||||
}
|
}
|
||||||
case bool:
|
case bool:
|
||||||
args = append(args, fmt.Sprintf("-%s=%t", key, val))
|
args = append(args, fmt.Sprintf("-%s=%t", tag, val))
|
||||||
case int:
|
case int:
|
||||||
args = append(args, fmt.Sprintf("-%s=%d", key, val))
|
args = append(args, fmt.Sprintf("-%s=%d", tag, val))
|
||||||
case int64:
|
case int64:
|
||||||
args = append(args, fmt.Sprintf("-%s=%d", key, val))
|
args = append(args, fmt.Sprintf("-%s=%d", tag, val))
|
||||||
case float32:
|
case float32:
|
||||||
args = append(args, fmt.Sprintf("-%s=%g", key, val))
|
args = append(args, fmt.Sprintf("-%s=%g", tag, val))
|
||||||
case float64:
|
case float64:
|
||||||
args = append(args, fmt.Sprintf("-%s=%g", key, val))
|
args = append(args, fmt.Sprintf("-%s=%g", tag, val))
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("write PDF metadata with ExifTool: unsupported type %T for key %q: %w", value, key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
return nil, fmt.Errorf("write PDF metadata with ExifTool: unsupported type %T for key %q: %w", value, key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
||||||
}
|
}
|
||||||
@@ -159,6 +206,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 +243,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 +303,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 +317,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 +331,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()
|
||||||
|
|
||||||
@@ -264,12 +341,26 @@ func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, format
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OptimizeImages is not available in this implementation.
|
||||||
|
func (engine *ExifTool) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
|
||||||
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.OptimizeImages",
|
||||||
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
|
)
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
err := fmt.Errorf("optimize PDF images with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// ReadMetadata extracts the metadata of a given PDF file by invoking
|
// ReadMetadata extracts the metadata of a given PDF file by invoking
|
||||||
// the exiftool binary with "-j" (JSON output) and parsing the result.
|
// the exiftool binary with "-j" (JSON output) and parsing the result.
|
||||||
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 +417,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 +462,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 +515,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 +529,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 +540,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 +557,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 +571,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 +585,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 +599,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 +614,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)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package exiftool
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -211,3 +212,48 @@ func TestSafeKeyPattern(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A metadata key that collides with an ExifTool option becomes a bare argv
|
||||||
|
// entry such as "-csv=/etc/passwd", which exiftool reads as its own option and
|
||||||
|
// treats the value as a filename to open.
|
||||||
|
func TestBuildExifToolWriteArgs_RejectsControlOptions(t *testing.T) {
|
||||||
|
for _, key := range []string{"csv", "CSV", "json", "geotag", "config", "tagsFromFile", "execute", "stay_open", "o", "w", "if", "p"} {
|
||||||
|
t.Run(key, func(t *testing.T) {
|
||||||
|
_, err := buildExifToolWriteArgs(map[string]any{key: "/etc/passwd"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("buildExifToolWriteArgs accepted the control option %q", key)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gotenberg.ErrPdfEngineMetadataValueNotSupported) {
|
||||||
|
t.Fatalf("error %v does not wrap ErrPdfEngineMetadataValueNotSupported", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A group prefix makes the key unambiguous, so it must still be accepted.
|
||||||
|
func TestBuildExifToolWriteArgs_AcceptsPrefixedOptionNames(t *testing.T) {
|
||||||
|
for _, key := range []string{"XMP:csv", "XMP-dc:json", "IPTC:p"} {
|
||||||
|
t.Run(key, func(t *testing.T) {
|
||||||
|
args, err := buildExifToolWriteArgs(map[string]any{key: "value"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildExifToolWriteArgs rejected the prefixed key %q: %v", key, err)
|
||||||
|
}
|
||||||
|
want := fmt.Sprintf("-%s=value", key)
|
||||||
|
if len(args) != 1 || args[0] != want {
|
||||||
|
t.Fatalf("args = %v, want [%s]", args, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordinary tags must be unaffected.
|
||||||
|
func TestBuildExifToolWriteArgs_AcceptsOrdinaryTags(t *testing.T) {
|
||||||
|
for _, key := range []string{"Author", "Title", "Subject", "Keywords", "Producer", "Creator"} {
|
||||||
|
t.Run(key, func(t *testing.T) {
|
||||||
|
_, err := buildExifToolWriteArgs(map[string]any{key: "value"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildExifToolWriteArgs rejected the ordinary tag %q: %v", key, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -32,11 +33,32 @@ var (
|
|||||||
// formats option.
|
// formats option.
|
||||||
ErrInvalidPdfFormats = errors.New("invalid PDF formats")
|
ErrInvalidPdfFormats = errors.New("invalid PDF formats")
|
||||||
|
|
||||||
// ErrUnoException happens when unoconverter returns exit code 5.
|
// ErrUnoException happens when unoconverter returns exit code 5. That code
|
||||||
|
// is the residual bucket of unoconverter's catch-all UNO exception handler:
|
||||||
|
// it covers a malformed page range, a password supplied to a document that
|
||||||
|
// does not need one, a failure to open the document and a failure to write
|
||||||
|
// the output alike. It names the exception class that was caught, not a
|
||||||
|
// cause. See https://github.com/gotenberg/gotenberg/issues/1588.
|
||||||
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")
|
// unoconverter's own message for it reads "Office probably died", yet a
|
||||||
|
// wrong or missing password also surfaces there. Like [ErrUnoException], it
|
||||||
|
// does not establish who is at fault.
|
||||||
|
ErrRuntimeException = errors.New("runtime exception")
|
||||||
|
|
||||||
|
// ErrIoException happens when unoconverter returns exit code 3. LibreOffice
|
||||||
|
// could not read the source document.
|
||||||
|
ErrIoException = errors.New("io exception")
|
||||||
|
|
||||||
|
// ErrCannotConvertException happens when unoconverter returns exit code 4.
|
||||||
|
// LibreOffice read the document but could not convert it to PDF.
|
||||||
|
ErrCannotConvertException = errors.New("cannot convert exception")
|
||||||
|
|
||||||
|
// ErrIllegalArgumentException happens when unoconverter returns exit code
|
||||||
|
// 8. LibreOffice rejected the source document, usually because its contents
|
||||||
|
// do not match its extension.
|
||||||
|
ErrIllegalArgumentException = errors.New("illegal argument 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 +75,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.
|
||||||
@@ -327,10 +353,11 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
fs.Duration("libreoffice-idle-shutdown-timeout", 0, "Shutdown LibreOffice after being idle for the given duration. Set to 0 to disable this feature")
|
fs.Duration("libreoffice-idle-shutdown-timeout", 0, "Shutdown LibreOffice after being idle for the given duration. Set to 0 to disable this feature")
|
||||||
fs.Bool("libreoffice-auto-start", false, "Automatically launch LibreOffice upon initialization if set to true; otherwise, LibreOffice will start at the time of the first conversion")
|
fs.Bool("libreoffice-auto-start", false, "Automatically launch LibreOffice upon initialization if set to true; otherwise, LibreOffice will start at the time of the first conversion")
|
||||||
fs.Duration("libreoffice-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for LibreOffice to start or restart")
|
fs.Duration("libreoffice-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for LibreOffice to start or restart")
|
||||||
fs.StringSlice("libreoffice-allow-list", []string{}, "Set the allowed URLs for LibreOffice outbound fetches (embedded images, linked content) using regular expressions - supports multiple values")
|
fs.StringSlice("libreoffice-allow-list", []string{}, `Set the allowed URLs for LibreOffice outbound fetches (embedded images, linked content) using regular expressions - supports multiple values. A match bypasses --libreoffice-deny-private-ips (LIBREOFFICE_DENY_PRIVATE_IPS) and --libreoffice-deny-public-ips (LIBREOFFICE_DENY_PUBLIC_IPS), so terminate the host or the pattern also matches suffix hosts, for example ^https?://internal\.svc(:|/|$)`)
|
||||||
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 +385,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 +398,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 +496,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 +514,19 @@ 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.args.proxyOptions.enableEnvironmentProxy {
|
||||||
|
proxyErr := gotenberg.ValidateEnvironmentProxyVariables()
|
||||||
|
if proxyErr != nil {
|
||||||
|
err = errors.Join(err, fmt.Errorf("--libreoffice-enable-environment-proxy is set: %w", proxyErr))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
@@ -530,19 +574,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 +689,126 @@ 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),
|
||||||
|
errors.Is(err, ErrIoException),
|
||||||
|
errors.Is(err, ErrCannotConvertException),
|
||||||
|
errors.Is(err, ErrIllegalArgumentException):
|
||||||
|
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 {
|
||||||
@@ -758,6 +879,8 @@ func (a *Api) Extensions() []string {
|
|||||||
".potx",
|
".potx",
|
||||||
".ppm",
|
".ppm",
|
||||||
".pps",
|
".pps",
|
||||||
|
".ppsm",
|
||||||
|
".ppsx",
|
||||||
".ppt",
|
".ppt",
|
||||||
".pptm",
|
".pptm",
|
||||||
".pptx",
|
".pptx",
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
36
pkg/modules/libreoffice/api/errortype_test.go
Normal file
36
pkg/modules/libreoffice/api/errortype_test.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
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"},
|
||||||
|
{"io exception", ErrIoException, "invalid_input"},
|
||||||
|
{"cannot convert exception", ErrCannotConvertException, "invalid_input"},
|
||||||
|
{"illegal argument exception", ErrIllegalArgumentException, "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"
|
||||||
@@ -67,13 +68,67 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
|
|||||||
|
|
||||||
userProfileDirPath := p.fs.NewDirPath()
|
userProfileDirPath := p.fs.NewDirPath()
|
||||||
|
|
||||||
|
var (
|
||||||
|
cmd *gotenberg.Cmd
|
||||||
|
success bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// Registered here, right after the proxy starts listening, so that every
|
||||||
|
// failure below tears it down. A return between the proxy start and this
|
||||||
|
// point strands it: its listener stays bound, its Serve goroutine and HTTP
|
||||||
|
// client stay alive, and p.proxy is only assigned on success, so nothing
|
||||||
|
// could ever reach it to stop it. exec.Cmd.Start fails precisely under fd
|
||||||
|
// or memory pressure, and the supervisor retries the launch on the next
|
||||||
|
// request, so each stranded proxy compounds the condition that caused it.
|
||||||
|
defer func() {
|
||||||
|
if success {
|
||||||
|
p.cfgMu.Lock()
|
||||||
|
defer p.cfgMu.Unlock()
|
||||||
|
|
||||||
|
p.socketPort = port
|
||||||
|
p.userProfileDirPath = userProfileDirPath
|
||||||
|
p.cmd = cmd
|
||||||
|
p.proxy = proxy
|
||||||
|
p.isStarted.Store(true)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibreOffice failed to start; tear the proxy down too.
|
||||||
|
stopErr := proxy.Stop(context.Background())
|
||||||
|
if stopErr != nil {
|
||||||
|
logger.WarnContext(context.Background(), fmt.Sprintf("stop LibreOffice outbound proxy after failed start: %s", stopErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let's make sure the process is killed. It is nil when the failure
|
||||||
|
// happened before the command was built.
|
||||||
|
if cmd != nil {
|
||||||
|
killErr := cmd.Kill()
|
||||||
|
if killErr != nil {
|
||||||
|
logger.DebugContext(context.Background(), fmt.Sprintf("kill LibreOffice process: %v", killErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the user profile directory is deleted. It may never have been
|
||||||
|
// created, which RemoveAll reports as success.
|
||||||
|
removeErr := os.RemoveAll(userProfileDirPath)
|
||||||
|
if removeErr != nil {
|
||||||
|
logger.ErrorContext(context.Background(), fmt.Sprintf("remove LibreOffice's user profile directory: %v", removeErr))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugContext(context.Background(), fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
|
||||||
|
}()
|
||||||
|
|
||||||
// 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
|
||||||
_ = proxy.Stop(context.Background())
|
// (file://) and direct fetches are dropped at the source.
|
||||||
return fmt.Errorf("write soffice proxy config: %w", err)
|
if err := writeSofficeProfileConfig(userProfileDirPath, proxy.Addr()); err != nil {
|
||||||
|
return fmt.Errorf("write soffice profile config: %w", err)
|
||||||
}
|
}
|
||||||
sofficeEnv := sofficeProxyEnv(os.Environ(), proxy.Addr())
|
sofficeEnv := sofficeProxyEnv(os.Environ(), proxy.Addr())
|
||||||
|
|
||||||
@@ -92,9 +147,8 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), p.arguments.startTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), p.arguments.startTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cmd, err := gotenberg.CommandContext(ctx, logger, p.arguments.binPath, args...)
|
cmd, err = gotenberg.CommandContext(ctx, logger, p.arguments.binPath, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = proxy.Stop(context.Background())
|
|
||||||
return fmt.Errorf("create LibreOffice command: %w", err)
|
return fmt.Errorf("create LibreOffice command: %w", err)
|
||||||
}
|
}
|
||||||
cmd.SetEnv(sofficeEnv)
|
cmd.SetEnv(sofficeEnv)
|
||||||
@@ -103,7 +157,6 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
|
|||||||
// able to run as a daemon.
|
// able to run as a daemon.
|
||||||
exitCode, err := cmd.Exec()
|
exitCode, err := cmd.Exec()
|
||||||
if err != nil && exitCode != 81 {
|
if err != nil && exitCode != 81 {
|
||||||
_ = proxy.Stop(context.Background())
|
|
||||||
return fmt.Errorf("execute LibreOffice: %w", err)
|
return fmt.Errorf("execute LibreOffice: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,50 +205,13 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var success bool
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
if success {
|
|
||||||
p.cfgMu.Lock()
|
|
||||||
defer p.cfgMu.Unlock()
|
|
||||||
|
|
||||||
p.socketPort = port
|
|
||||||
p.userProfileDirPath = userProfileDirPath
|
|
||||||
p.cmd = cmd
|
|
||||||
p.proxy = proxy
|
|
||||||
p.isStarted.Store(true)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// LibreOffice failed to start; tear the proxy down too.
|
|
||||||
stopErr := proxy.Stop(context.Background())
|
|
||||||
if stopErr != nil {
|
|
||||||
logger.WarnContext(context.Background(), fmt.Sprintf("stop LibreOffice outbound proxy after failed start: %s", stopErr))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Let's make sure the process is killed.
|
|
||||||
err = cmd.Kill()
|
|
||||||
if err != nil {
|
|
||||||
logger.DebugContext(context.Background(), fmt.Sprintf("kill LibreOffice process: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
// And the user profile directory is deleted.
|
|
||||||
err = os.RemoveAll(userProfileDirPath)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorContext(context.Background(), fmt.Sprintf("remove LibreOffice's user profile directory: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.DebugContext(context.Background(), fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
|
|
||||||
}()
|
|
||||||
|
|
||||||
logger.DebugContext(context.Background(), "waiting for the LibreOffice socket to be available...")
|
logger.DebugContext(context.Background(), "waiting for the LibreOffice socket to be available...")
|
||||||
|
|
||||||
for {
|
for {
|
||||||
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 +219,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,6 +306,14 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, input
|
|||||||
return errors.New("LibreOffice not started, cannot handle PDF conversion")
|
return errors.New("LibreOffice not started, cannot handle PDF conversion")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SinglePageSheets starts each sheet's single page at the workbook's saved
|
||||||
|
// scroll position, truncating everything above and to the left of it.
|
||||||
|
// Render a copy with that position reset to the top-left cell instead.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1222.
|
||||||
|
if options.SinglePageSheets {
|
||||||
|
inputPath = resetCalcScrollPosition(ctx, logger, inputPath)
|
||||||
|
}
|
||||||
|
|
||||||
args := []string{
|
args := []string{
|
||||||
"--no-launch",
|
"--no-launch",
|
||||||
"--format",
|
"--format",
|
||||||
@@ -319,6 +343,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))
|
||||||
@@ -422,9 +456,11 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, input
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LibreOffice's errors are not explicit.
|
// LibreOffice's errors are not explicit: unoconverter derives its exit code
|
||||||
// For instance, exit code 5 may be explained by a malformed page range
|
// from the UNO exception class it caught, not from a diagnosis. Exit codes
|
||||||
// but also by a not required password.
|
// 5 and 6 are ambiguous in particular, so the route decides the HTTP status
|
||||||
|
// from the request and the document rather than from the code alone.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1588.
|
||||||
|
|
||||||
// We may want to retry in case of a core-dumped event.
|
// We may want to retry in case of a core-dumped event.
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/639.
|
// See https://github.com/gotenberg/gotenberg/issues/639.
|
||||||
@@ -432,13 +468,17 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, input
|
|||||||
return ErrCoreDumped
|
return ErrCoreDumped
|
||||||
}
|
}
|
||||||
|
|
||||||
if exitCode == 5 {
|
switch exitCode {
|
||||||
// Potentially malformed page ranges or password not required.
|
case 3:
|
||||||
|
return ErrIoException
|
||||||
|
case 4:
|
||||||
|
return ErrCannotConvertException
|
||||||
|
case 5:
|
||||||
return ErrUnoException
|
return ErrUnoException
|
||||||
}
|
case 6:
|
||||||
if exitCode == 6 {
|
|
||||||
// Password potentially required or invalid.
|
|
||||||
return ErrRuntimeException
|
return ErrRuntimeException
|
||||||
|
case 8:
|
||||||
|
return ErrIllegalArgumentException
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("convert to PDF: %w", err)
|
return fmt.Errorf("convert to PDF: %w", err)
|
||||||
|
|||||||
130
pkg/modules/libreoffice/api/protection.go
Normal file
130
pkg/modules/libreoffice/api/protection.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PasswordProtection describes whether a document requires a password to open.
|
||||||
|
type PasswordProtection int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// PasswordProtectionUnknown means the document's encryption state could not
|
||||||
|
// be determined.
|
||||||
|
PasswordProtectionUnknown PasswordProtection = iota
|
||||||
|
|
||||||
|
// PasswordProtectionNone means the document opens without a password.
|
||||||
|
PasswordProtectionNone
|
||||||
|
|
||||||
|
// PasswordProtectionRequired means the document is encrypted.
|
||||||
|
PasswordProtectionRequired
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Compound File Binary magic. An encrypted OOXML document is an
|
||||||
|
// MS-OFFCRYPTO container, which is a compound file. Per MS-CFB 2.2, the
|
||||||
|
// header signature is fixed.
|
||||||
|
ole2Magic = []byte{0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1}
|
||||||
|
|
||||||
|
// Local file header signature. Per APPNOTE.TXT 4.3.7, every ZIP entry
|
||||||
|
// starts with it, so an intact package starts with it too.
|
||||||
|
zipMagic = []byte{0x50, 0x4b, 0x03, 0x04}
|
||||||
|
|
||||||
|
// An unencrypted OOXML document is always a ZIP package, so any of these
|
||||||
|
// extensions over a compound file means the payload is encrypted. A .xlsb
|
||||||
|
// workbook stores binary parts inside that same ZIP package, so it belongs
|
||||||
|
// here too. Legacy binary formats (.doc, .xls, .ppt) are compound files
|
||||||
|
// either way and are deliberately absent.
|
||||||
|
ooxmlExtensions = map[string]struct{}{
|
||||||
|
".docx": {}, ".docm": {}, ".dotx": {}, ".dotm": {},
|
||||||
|
".xlsx": {}, ".xlsm": {}, ".xltx": {}, ".xltm": {}, ".xlsb": {},
|
||||||
|
".pptx": {}, ".pptm": {}, ".potx": {}, ".potm": {},
|
||||||
|
".ppsx": {}, ".ppsm": {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// odfManifestSizeLimit caps how much of an ODF manifest is read. The manifest
|
||||||
|
// is a few kilobytes in practice; the cap stops a crafted archive from
|
||||||
|
// exhausting memory through its decompressed size.
|
||||||
|
const odfManifestSizeLimit = 1 << 20
|
||||||
|
|
||||||
|
// DetectPasswordProtection reports whether the document at path is encrypted.
|
||||||
|
//
|
||||||
|
// Detection is advisory and never fails: an unreadable file, an unknown format
|
||||||
|
// or a malformed archive all yield [PasswordProtectionUnknown]. It exists to
|
||||||
|
// refine the diagnosis of a conversion that already failed, since LibreOffice's
|
||||||
|
// exit codes do not distinguish a missing password from a crash.
|
||||||
|
func DetectPasswordProtection(path string) PasswordProtection {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return PasswordProtectionUnknown
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = f.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
magic := make([]byte, 8)
|
||||||
|
n, err := io.ReadFull(f, magic)
|
||||||
|
if err != nil && n < len(zipMagic) {
|
||||||
|
return PasswordProtectionUnknown
|
||||||
|
}
|
||||||
|
magic = magic[:n]
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case bytes.HasPrefix(magic, ole2Magic):
|
||||||
|
if _, ok := ooxmlExtensions[strings.ToLower(filepath.Ext(path))]; ok {
|
||||||
|
return PasswordProtectionRequired
|
||||||
|
}
|
||||||
|
// A legacy binary document is a compound file whether or not it is
|
||||||
|
// encrypted; its encryption lives in a stream this cannot cheaply read.
|
||||||
|
return PasswordProtectionUnknown
|
||||||
|
case bytes.HasPrefix(magic, zipMagic):
|
||||||
|
return detectZipPasswordProtection(f)
|
||||||
|
default:
|
||||||
|
// Flat XML (.fodt), RTF, CSV and everything else carry no encryption.
|
||||||
|
return PasswordProtectionUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectZipPasswordProtection inspects a ZIP package. ODF keeps META-INF/manifest.xml
|
||||||
|
// in cleartext even when encrypted, declaring each encrypted entry. An OOXML
|
||||||
|
// package has no manifest, and reaching this point already proves it is not an
|
||||||
|
// MS-OFFCRYPTO container, so it opens without a password.
|
||||||
|
func detectZipPasswordProtection(f *os.File) PasswordProtection {
|
||||||
|
size, err := f.Seek(0, io.SeekEnd)
|
||||||
|
if err != nil {
|
||||||
|
return PasswordProtectionUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := zip.NewReader(f, size)
|
||||||
|
if err != nil {
|
||||||
|
return PasswordProtectionUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, err := r.Open("META-INF/manifest.xml")
|
||||||
|
if err != nil {
|
||||||
|
// No manifest: an OOXML package, or a ZIP that is not an office
|
||||||
|
// document at all. Neither is encrypted.
|
||||||
|
return PasswordProtectionNone
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = manifest.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
content, err := io.ReadAll(io.LimitReader(manifest, odfManifestSizeLimit))
|
||||||
|
if err != nil {
|
||||||
|
return PasswordProtectionUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per OpenDocument 1.3 part 3, section 4.16, an encrypted entry carries a
|
||||||
|
// <manifest:encryption-data> child.
|
||||||
|
if bytes.Contains(content, []byte("encryption-data")) {
|
||||||
|
return PasswordProtectionRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
return PasswordProtectionNone
|
||||||
|
}
|
||||||
198
pkg/modules/libreoffice/api/protection_test.go
Normal file
198
pkg/modules/libreoffice/api/protection_test.go
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeFile writes content to a new file named name inside dir and returns its
|
||||||
|
// path.
|
||||||
|
func writeFile(t *testing.T, dir, name string, content []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
err := os.WriteFile(path, content, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeZip builds a ZIP archive from entries and returns its path.
|
||||||
|
func writeZip(t *testing.T, dir, name string, entries map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
w := zip.NewWriter(buf)
|
||||||
|
|
||||||
|
for entryName, content := range entries {
|
||||||
|
f, err := w.Create(entryName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create zip entry %s: %v", entryName, err)
|
||||||
|
}
|
||||||
|
_, err = f.Write([]byte(content))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write zip entry %s: %v", entryName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := w.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close zip writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return writeFile(t, dir, name, buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectPasswordProtection(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
ole2 := func(name string) string {
|
||||||
|
return writeFile(t, dir, name, append(ole2Magic, bytes.Repeat([]byte{0x00}, 64)...))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
want PasswordProtection
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "encrypted OOXML is a compound file",
|
||||||
|
path: ole2("encrypted.docx"),
|
||||||
|
want: PasswordProtectionRequired,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extension casing is ignored",
|
||||||
|
path: ole2("encrypted.DOCX"),
|
||||||
|
want: PasswordProtectionRequired,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "encrypted spreadsheet",
|
||||||
|
path: ole2("encrypted.xlsx"),
|
||||||
|
want: PasswordProtectionRequired,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "encrypted binary workbook",
|
||||||
|
path: ole2("encrypted.xlsb"),
|
||||||
|
want: PasswordProtectionRequired,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "legacy binary document is inconclusive",
|
||||||
|
path: ole2("legacy.doc"),
|
||||||
|
want: PasswordProtectionUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain OOXML package",
|
||||||
|
path: writeZip(t, dir, "plain.docx", map[string]string{
|
||||||
|
"[Content_Types].xml": "<Types/>",
|
||||||
|
"word/document.xml": "<w:document/>",
|
||||||
|
}),
|
||||||
|
want: PasswordProtectionNone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "encrypted ODF declares encryption-data in its manifest",
|
||||||
|
path: writeZip(t, dir, "encrypted.odt", map[string]string{
|
||||||
|
"mimetype": "application/vnd.oasis.opendocument.text",
|
||||||
|
"META-INF/manifest.xml": `<manifest:manifest><manifest:file-entry><manifest:encryption-data manifest:checksum="x"/></manifest:file-entry></manifest:manifest>`,
|
||||||
|
"content.xml": "<office:document-content/>",
|
||||||
|
}),
|
||||||
|
want: PasswordProtectionRequired,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain ODF has a manifest without encryption-data",
|
||||||
|
path: writeZip(t, dir, "plain.odt", map[string]string{
|
||||||
|
"mimetype": "application/vnd.oasis.opendocument.text",
|
||||||
|
"META-INF/manifest.xml": `<manifest:manifest><manifest:file-entry manifest:full-path="/"/></manifest:manifest>`,
|
||||||
|
"content.xml": "<office:document-content/>",
|
||||||
|
}),
|
||||||
|
want: PasswordProtectionNone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "flat XML carries no encryption",
|
||||||
|
path: writeFile(t, dir, "flat.fodt", []byte("<?xml version=\"1.0\"?><office:document/>")),
|
||||||
|
want: PasswordProtectionUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain text",
|
||||||
|
path: writeFile(t, dir, "notes.txt", []byte("hello")),
|
||||||
|
want: PasswordProtectionUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file shorter than any magic",
|
||||||
|
path: writeFile(t, dir, "tiny.docx", []byte{0x50}),
|
||||||
|
want: PasswordProtectionUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty file",
|
||||||
|
path: writeFile(t, dir, "empty.docx", nil),
|
||||||
|
want: PasswordProtectionUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "truncated archive",
|
||||||
|
path: writeFile(t, dir, "truncated.docx", append(zipMagic, bytes.Repeat([]byte{0x00}, 32)...)),
|
||||||
|
want: PasswordProtectionUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-existent path",
|
||||||
|
path: filepath.Join(dir, "does-not-exist.docx"),
|
||||||
|
want: PasswordProtectionUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "directory",
|
||||||
|
path: dir,
|
||||||
|
want: PasswordProtectionUnknown,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := DetectPasswordProtection(tc.path); got != tc.want {
|
||||||
|
t.Errorf("DetectPasswordProtection(%s) = %d, want %d", tc.path, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDetectPasswordProtection_Fixtures anchors detection to the same documents
|
||||||
|
// the integration scenarios upload, so a fixture swap cannot silently flip a
|
||||||
|
// status code.
|
||||||
|
func TestDetectPasswordProtection_Fixtures(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
path string
|
||||||
|
want PasswordProtection
|
||||||
|
}{
|
||||||
|
{"../../../../test/integration/testdata/protected_page_1.docx", PasswordProtectionRequired},
|
||||||
|
{"../../../../test/integration/testdata/page_1.docx", PasswordProtectionNone},
|
||||||
|
} {
|
||||||
|
t.Run(filepath.Base(tc.path), func(t *testing.T) {
|
||||||
|
if _, err := os.Stat(tc.path); err != nil {
|
||||||
|
t.Skipf("fixture unavailable: %v", err)
|
||||||
|
}
|
||||||
|
if got := DetectPasswordProtection(tc.path); got != tc.want {
|
||||||
|
t.Errorf("DetectPasswordProtection(%s) = %d, want %d", tc.path, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDetectPasswordProtection_OversizedManifest verifies that a manifest far
|
||||||
|
// larger than the cap still yields a verdict through a bounded read.
|
||||||
|
func TestDetectPasswordProtection_OversizedManifest(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Well past odfManifestSizeLimit, and highly compressible, so the archive
|
||||||
|
// on disk stays small.
|
||||||
|
filler := strings.Repeat("<manifest:file-entry manifest:full-path=\"pad\"/>", 200_000)
|
||||||
|
|
||||||
|
path := writeZip(t, dir, "oversized.odt", map[string]string{
|
||||||
|
"mimetype": "application/vnd.oasis.opendocument.text",
|
||||||
|
"META-INF/manifest.xml": "<manifest:manifest>" + filler + "</manifest:manifest>",
|
||||||
|
})
|
||||||
|
|
||||||
|
if got := DetectPasswordProtection(path); got != PasswordProtectionNone {
|
||||||
|
t.Errorf("DetectPasswordProtection(oversized) = %d, want %d", got, PasswordProtectionNone)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
@@ -358,7 +361,7 @@ func TestSofficeProxyEnv_OverridesExisting(t *testing.T) {
|
|||||||
// Old proxy values must be gone, not duplicated. Count exact-case keys.
|
// Old proxy values must be gone, not duplicated. Count exact-case keys.
|
||||||
counts := map[string]int{}
|
counts := map[string]int{}
|
||||||
for _, kv := range out {
|
for _, kv := range out {
|
||||||
key := strings.SplitN(kv, "=", 2)[0]
|
key, _, _ := strings.Cut(kv, "=")
|
||||||
counts[key]++
|
counts[key]++
|
||||||
}
|
}
|
||||||
for _, key := range []string{"http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY", "no_proxy", "NO_PROXY"} {
|
for _, key := range []string{"http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY", "no_proxy", "NO_PROXY"} {
|
||||||
|
|||||||
230
pkg/modules/libreoffice/api/singlepagesheets.go
Normal file
230
pkg/modules/libreoffice/api/singlepagesheets.go
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// topLeftCellAttr matches the topLeftCell attribute that an OOXML worksheet
|
||||||
|
// uses (on <sheetView> and, for frozen panes, <pane>) to store the cell that
|
||||||
|
// was at the top-left of the window when the workbook was saved.
|
||||||
|
var topLeftCellAttr = regexp.MustCompile(` topLeftCell="[^"]*"`)
|
||||||
|
|
||||||
|
// maxDecompressedWorksheet bounds how much a single worksheet may decompress to
|
||||||
|
// while rewriting it. It guards against a decompression bomb and keeps memory
|
||||||
|
// predictable. A worksheet larger than this is left untouched, so a pathological
|
||||||
|
// workbook falls back to the original file rather than being rewritten.
|
||||||
|
const maxDecompressedWorksheet = 128 << 20 // 128 MiB
|
||||||
|
|
||||||
|
// resetCalcScrollPosition returns a path to a copy of inputPath whose worksheet
|
||||||
|
// scroll positions have been reset to the top-left cell, or inputPath unchanged
|
||||||
|
// when the reset does not apply or cannot be performed safely.
|
||||||
|
//
|
||||||
|
// LibreOffice's SinglePageSheets export starts each single page at the sheet's
|
||||||
|
// saved topLeftCell, dropping every row and column above and to the left of it.
|
||||||
|
// A workbook saved scrolled away from A1 therefore renders truncated. Removing
|
||||||
|
// the attribute before the conversion makes the whole used range render.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1222.
|
||||||
|
//
|
||||||
|
// The function never fails the conversion. On a non-xlsx input, a workbook that
|
||||||
|
// carries no scroll position, or any read, rewrite or validation error, it
|
||||||
|
// returns the original path so a malformed rewrite can never reach LibreOffice.
|
||||||
|
func resetCalcScrollPosition(ctx context.Context, logger *slog.Logger, inputPath string) string {
|
||||||
|
// Resolve the extension to a literal so the sanitized filename is never
|
||||||
|
// derived from the (user-controlled) upload name.
|
||||||
|
var ext string
|
||||||
|
switch strings.ToLower(filepath.Ext(inputPath)) {
|
||||||
|
case ".xlsx":
|
||||||
|
ext = ".xlsx"
|
||||||
|
case ".xlsm":
|
||||||
|
ext = ".xlsm"
|
||||||
|
default:
|
||||||
|
return inputPath
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := os.ReadFile(inputPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: read input: %s; using the original file", err))
|
||||||
|
return inputPath
|
||||||
|
}
|
||||||
|
|
||||||
|
out, changed, err := stripWorksheetScrollPosition(src)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: %s; using the original file", err))
|
||||||
|
return inputPath
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
// The common case: nothing was saved scrolled, so nothing to do.
|
||||||
|
return inputPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rewrite that dropped, renamed or corrupted an entry must never reach
|
||||||
|
// LibreOffice; fall back to the original workbook if it does not round-trip.
|
||||||
|
if err = validateWorkbook(src, out); err != nil {
|
||||||
|
logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: %s; using the original file", err))
|
||||||
|
return inputPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the sanitized copy alongside the input, inside the request working
|
||||||
|
// directory that LibreOffice already reads from. The pattern is constant,
|
||||||
|
// so the resulting name carries no user-controlled path component.
|
||||||
|
dst, err := os.CreateTemp(filepath.Dir(inputPath), "singlepagesheets-*"+ext)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: create sanitized file: %s; using the original file", err))
|
||||||
|
return inputPath
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
_, err = dst.Write(out)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(dst.Name())
|
||||||
|
logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: write sanitized file: %s; using the original file", err))
|
||||||
|
return inputPath
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugContext(ctx, "reset calc scroll position: cleared worksheet topLeftCell for SinglePageSheets export")
|
||||||
|
return dst.Name()
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripWorksheetScrollPosition rewrites the worksheet XML entries of an xlsx
|
||||||
|
// workbook, removing the topLeftCell attribute, and reports whether anything
|
||||||
|
// changed. Every non-worksheet entry, and every worksheet that does not carry
|
||||||
|
// the attribute, is copied byte-for-byte without recompression.
|
||||||
|
func stripWorksheetScrollPosition(src []byte) ([]byte, bool, error) {
|
||||||
|
reader, err := zip.NewReader(bytes.NewReader(src), int64(len(src)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("open workbook: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writer := zip.NewWriter(&buf)
|
||||||
|
changed := false
|
||||||
|
|
||||||
|
for _, file := range reader.File {
|
||||||
|
rewritten, ok, err := rewriteWorksheet(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
// Recompress only the worksheets that actually changed.
|
||||||
|
header := file.FileHeader
|
||||||
|
header.Method = zip.Deflate
|
||||||
|
w, err := writer.CreateHeader(&header)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("write worksheet %q: %w", file.Name, err)
|
||||||
|
}
|
||||||
|
_, err = w.Write(rewritten)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("write worksheet %q: %w", file.Name, err)
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
err = copyZipEntry(writer, file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writer.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("finalize workbook: %w", err)
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
return buf.Bytes(), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rewriteWorksheet returns file's contents with topLeftCell removed, and
|
||||||
|
// whether file is a worksheet that carried the attribute. A worksheet without
|
||||||
|
// the attribute, or any other entry, returns ok false so the caller copies it
|
||||||
|
// verbatim.
|
||||||
|
func rewriteWorksheet(file *zip.File) ([]byte, bool, error) {
|
||||||
|
if !strings.HasPrefix(file.Name, "xl/worksheets/") || !strings.HasSuffix(strings.ToLower(file.Name), ".xml") {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rc, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("open worksheet %q: %w", file.Name, err)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
// Read at most maxDecompressedWorksheet+1 bytes so a decompression bomb
|
||||||
|
// cannot exhaust memory; a genuine overflow aborts the rewrite.
|
||||||
|
data, err := io.ReadAll(io.LimitReader(rc, maxDecompressedWorksheet+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("read worksheet %q: %w", file.Name, err)
|
||||||
|
}
|
||||||
|
if len(data) > maxDecompressedWorksheet {
|
||||||
|
return nil, false, fmt.Errorf("worksheet %q exceeds %d bytes", file.Name, maxDecompressedWorksheet)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Contains(data, []byte("topLeftCell")) {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
return topLeftCellAttr.ReplaceAll(data, nil), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyZipEntry writes file into writer without decompressing and recompressing
|
||||||
|
// it, preserving its exact bytes.
|
||||||
|
func copyZipEntry(writer *zip.Writer, file *zip.File) error {
|
||||||
|
w, err := writer.CreateRaw(&file.FileHeader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("copy entry %q: %w", file.Name, err)
|
||||||
|
}
|
||||||
|
rc, err := file.OpenRaw()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open entry %q: %w", file.Name, err)
|
||||||
|
}
|
||||||
|
_, err = io.Copy(w, rc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("copy entry %q: %w", file.Name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateWorkbook checks that out reopens as a zip holding exactly the same
|
||||||
|
// entry names as src, rejecting a rewrite that lost, renamed or added an entry
|
||||||
|
// or produced a broken central directory. The entry payloads themselves are
|
||||||
|
// not re-read: unchanged entries are copied byte-for-byte from a workbook that
|
||||||
|
// already parsed, and rewritten worksheets are produced by the standard library
|
||||||
|
// writer, so re-decompressing everything would only add a decompression-bomb
|
||||||
|
// surface without catching a failure this transform can introduce.
|
||||||
|
func validateWorkbook(src, out []byte) error {
|
||||||
|
original, err := zip.NewReader(bytes.NewReader(src), int64(len(src)))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reopen original workbook: %w", err)
|
||||||
|
}
|
||||||
|
rewritten, err := zip.NewReader(bytes.NewReader(out), int64(len(out)))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reopen rewritten workbook: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rewritten.File) != len(original.File) {
|
||||||
|
return fmt.Errorf("entry count changed from %d to %d", len(original.File), len(rewritten.File))
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make(map[string]struct{}, len(original.File))
|
||||||
|
for _, file := range original.File {
|
||||||
|
names[file.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, file := range rewritten.File {
|
||||||
|
_, ok := names[file.Name]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unexpected entry %q", file.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
216
pkg/modules/libreoffice/api/singlepagesheets_test.go
Normal file
216
pkg/modules/libreoffice/api/singlepagesheets_test.go
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildWorkbook packs entries into an in-memory xlsx-like zip.
|
||||||
|
func buildWorkbook(t *testing.T, entries map[string]string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := zip.NewWriter(&buf)
|
||||||
|
for name, content := range entries {
|
||||||
|
f, err := w.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create entry %q: %v", name, err)
|
||||||
|
}
|
||||||
|
_, err = f.Write([]byte(content))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write entry %q: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := w.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close workbook: %v", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func readEntry(t *testing.T, workbook []byte, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
r, err := zip.NewReader(bytes.NewReader(workbook), int64(len(workbook)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open workbook: %v", err)
|
||||||
|
}
|
||||||
|
for _, f := range r.File {
|
||||||
|
if f.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open entry %q: %v", name, err)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
data, err := io.ReadAll(rc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read entry %q: %v", name, err)
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
t.Fatalf("entry %q not found", name)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrolledSheet = `<?xml version="1.0"?><worksheet><dimension ref="A1:B83"/>` +
|
||||||
|
`<sheetViews><sheetView tabSelected="1" topLeftCell="A37" workbookViewId="0">` +
|
||||||
|
`<pane topLeftCell="A37"/><selection activeCell="A1" sqref="A1"/></sheetView></sheetViews>` +
|
||||||
|
`<sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData></worksheet>`
|
||||||
|
|
||||||
|
const topSheet = `<?xml version="1.0"?><worksheet><dimension ref="A1:B83"/>` +
|
||||||
|
`<sheetViews><sheetView tabSelected="1" workbookViewId="0"/></sheetViews>` +
|
||||||
|
`<sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData></worksheet>`
|
||||||
|
|
||||||
|
func TestStripWorksheetScrollPosition(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
workbook []byte
|
||||||
|
expectErr bool
|
||||||
|
expectChange bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "removes topLeftCell from sheetView and pane",
|
||||||
|
workbook: buildWorkbook(t, map[string]string{
|
||||||
|
"[Content_Types].xml": "<Types/>",
|
||||||
|
"xl/worksheets/sheet1.xml": scrolledSheet,
|
||||||
|
"xl/sharedStrings.xml": "<sst/>",
|
||||||
|
}),
|
||||||
|
expectChange: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "leaves a workbook without a saved scroll position untouched",
|
||||||
|
workbook: buildWorkbook(t, map[string]string{
|
||||||
|
"[Content_Types].xml": "<Types/>",
|
||||||
|
"xl/worksheets/sheet1.xml": topSheet,
|
||||||
|
}),
|
||||||
|
expectChange: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "only rewrites worksheet entries",
|
||||||
|
workbook: buildWorkbook(t, map[string]string{
|
||||||
|
"xl/worksheets/sheet1.xml": scrolledSheet,
|
||||||
|
// A stray topLeftCell elsewhere must not be touched.
|
||||||
|
"xl/workbook.xml": `<workbook topLeftCell="A9"/>`,
|
||||||
|
}),
|
||||||
|
expectChange: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "rejects a non-zip input",
|
||||||
|
workbook: []byte("not a zip file"),
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
out, changed, err := stripWorksheetScrollPosition(tc.workbook)
|
||||||
|
|
||||||
|
if tc.expectErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got nil")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if changed != tc.expectChange {
|
||||||
|
t.Fatalf("expected changed=%v, got %v", tc.expectChange, changed)
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rewrite must round-trip and hold the same entries.
|
||||||
|
if err = validateWorkbook(tc.workbook, out); err != nil {
|
||||||
|
t.Fatalf("rewritten workbook did not validate: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(readEntry(t, out, "xl/worksheets/sheet1.xml"), "topLeftCell") {
|
||||||
|
t.Fatalf("worksheet still contains topLeftCell")
|
||||||
|
}
|
||||||
|
// Non-worksheet entries are copied verbatim.
|
||||||
|
if _, ok := entryNames(t, out)["xl/workbook.xml"]; ok {
|
||||||
|
if got := readEntry(t, out, "xl/workbook.xml"); got != `<workbook topLeftCell="A9"/>` {
|
||||||
|
t.Fatalf("non-worksheet entry was modified: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func entryNames(t *testing.T, workbook []byte) map[string]struct{} {
|
||||||
|
t.Helper()
|
||||||
|
r, err := zip.NewReader(bytes.NewReader(workbook), int64(len(workbook)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open workbook: %v", err)
|
||||||
|
}
|
||||||
|
names := make(map[string]struct{}, len(r.File))
|
||||||
|
for _, f := range r.File {
|
||||||
|
names[f.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetCalcScrollPosition(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
writeFile := func(t *testing.T, name string, content []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), name)
|
||||||
|
err := os.WriteFile(path, content, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write %q: %v", name, err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("non-xlsx input is returned unchanged", func(t *testing.T) {
|
||||||
|
path := writeFile(t, "input.docx", []byte("whatever"))
|
||||||
|
if got := resetCalcScrollPosition(ctx, logger, path); got != path {
|
||||||
|
t.Fatalf("expected %q, got %q", path, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("workbook without a scroll position is returned unchanged", func(t *testing.T) {
|
||||||
|
path := writeFile(t, "input.xlsx", buildWorkbook(t, map[string]string{
|
||||||
|
"xl/worksheets/sheet1.xml": topSheet,
|
||||||
|
}))
|
||||||
|
if got := resetCalcScrollPosition(ctx, logger, path); got != path {
|
||||||
|
t.Fatalf("expected original path %q, got %q", path, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("corrupt xlsx falls back to the original path", func(t *testing.T) {
|
||||||
|
path := writeFile(t, "input.xlsx", []byte("PK\x03\x04 not really a zip"))
|
||||||
|
if got := resetCalcScrollPosition(ctx, logger, path); got != path {
|
||||||
|
t.Fatalf("expected fallback to %q, got %q", path, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("scrolled workbook yields a sanitized copy", func(t *testing.T) {
|
||||||
|
path := writeFile(t, "input.xlsx", buildWorkbook(t, map[string]string{
|
||||||
|
"[Content_Types].xml": "<Types/>",
|
||||||
|
"xl/worksheets/sheet1.xml": scrolledSheet,
|
||||||
|
}))
|
||||||
|
got := resetCalcScrollPosition(ctx, logger, path)
|
||||||
|
if got == path {
|
||||||
|
t.Fatalf("expected a sanitized copy, got the original path")
|
||||||
|
}
|
||||||
|
if filepath.Dir(got) != filepath.Dir(path) {
|
||||||
|
t.Fatalf("sanitized copy escaped the working directory: %q", got)
|
||||||
|
}
|
||||||
|
sanitized, err := os.ReadFile(got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read sanitized file: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(readEntry(t, sanitized, "xl/worksheets/sheet1.xml"), "topLeftCell") {
|
||||||
|
t.Fatalf("sanitized worksheet still contains topLeftCell")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -60,6 +60,11 @@ func (engine *LibreOfficePdfEngine) Flatten(ctx context.Context, logger *slog.Lo
|
|||||||
return fmt.Errorf("flatten PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
return fmt.Errorf("flatten PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OptimizeImages is not available in this implementation.
|
||||||
|
func (engine *LibreOfficePdfEngine) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
|
||||||
|
return fmt.Errorf("optimize PDF images with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
// Convert converts the given PDF to a specific PDF format. Currently, only the
|
// Convert converts the given PDF to a specific PDF format. Currently, only the
|
||||||
// PDF/A-1b, PDF/A-2b, PDF/A-3b and PDF/UA formats are available. If another
|
// PDF/A-1b, PDF/A-2b, PDF/A-3b and PDF/UA formats are available. If another
|
||||||
// PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported]
|
// PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported]
|
||||||
@@ -106,7 +111,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 +140,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)
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import (
|
|||||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// unattributableFailureMessage is returned when LibreOffice fails and no
|
||||||
|
// client-supplied input is implicated. Its only format verb is the original
|
||||||
|
// filename.
|
||||||
|
const unattributableFailureMessage = "LibreOffice failed to convert the document '%s'. This is usually a resource issue: increase the container's memory and CPU, or reduce the document's size. The request is valid and may be retried."
|
||||||
|
|
||||||
// convertRoute returns an [api.Route] which can convert LibreOffice documents
|
// convertRoute returns an [api.Route] which can convert LibreOffice documents
|
||||||
// to PDF.
|
// to PDF.
|
||||||
func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) api.Route {
|
func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) api.Route {
|
||||||
@@ -30,14 +35,22 @@ 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)
|
watermarks, wErr := pdfengines.FormDataPdfWatermarks(form)
|
||||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
if wErr != nil {
|
||||||
stamp := pdfengines.FormDataPdfStamp(form, false)
|
return fmt.Errorf("form data watermarks: %w", wErr)
|
||||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
}
|
||||||
|
stamps, sErr := pdfengines.FormDataPdfStamps(form)
|
||||||
|
if sErr != nil {
|
||||||
|
return fmt.Errorf("form data stamps: %w", sErr)
|
||||||
|
}
|
||||||
|
var watermarkFiles, stampFiles []string
|
||||||
|
form.Watermarks(&watermarkFiles).Stamps(&stampFiles)
|
||||||
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)
|
||||||
|
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
|
||||||
|
|
||||||
zeroValuedSplitMode := gotenberg.SplitMode{}
|
zeroValuedSplitMode := gotenberg.SplitMode{}
|
||||||
|
|
||||||
@@ -304,22 +317,36 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
return fmt.Errorf("validate form data: %w", err)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
|
err = pdfengines.BindWatermarkFiles(watermarks, watermarkFiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate watermark: %w", err)
|
return fmt.Errorf("bind watermark files: %w", err)
|
||||||
}
|
}
|
||||||
err = pdfengines.EnsureStampFile(&stamp, stampFile)
|
err = pdfengines.BindStampFiles(stamps, stampFiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate stamp: %w", err)
|
return fmt.Errorf("bind stamp files: %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
|
||||||
}
|
}
|
||||||
|
|
||||||
hasPostProcessing := watermark.Source != "" || stamp.Source != "" || angle != 0 ||
|
err = pdfengines.ValidatePdfEncryptCompat(encrypt)
|
||||||
len(embedPaths) > 0 || len(metadata) > 0 || flatten
|
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 := len(watermarks) > 0 || len(stamps) > 0 || angle != 0 ||
|
||||||
|
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,25 +412,57 @@ 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),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, libreofficeapi.ErrUnoException) {
|
filename := ctx.OriginalFilename(inputPath)
|
||||||
|
|
||||||
|
if errors.Is(err, libreofficeapi.ErrIoException) || errors.Is(err, libreofficeapi.ErrIllegalArgumentException) {
|
||||||
return api.WrapError(
|
return api.WrapError(
|
||||||
fmt.Errorf("convert to PDF: %w", err),
|
fmt.Errorf("convert to PDF: %w", err),
|
||||||
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice failed to process a document: possible causes include malformed page ranges '%s' (nativePageRanges), or, if a password has been provided, it may not be required. In any case, the exact cause is uncertain.", options.PageRanges)),
|
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice could not read the document '%s'. Ensure the file is not corrupted and that its extension matches its actual format.", filename)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, libreofficeapi.ErrRuntimeException) {
|
if errors.Is(err, libreofficeapi.ErrCannotConvertException) {
|
||||||
return api.WrapError(
|
return api.WrapError(
|
||||||
fmt.Errorf("convert to PDF: %w", err),
|
fmt.Errorf("convert to PDF: %w", err),
|
||||||
api.NewSentinelHttpError(http.StatusBadRequest, "LibreOffice failed to process a document: a password may be required, or, if one has been given, it is invalid. In any case, the exact cause is uncertain."),
|
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice read the document '%s' but could not convert it to PDF. The document may be corrupted or rely on an unsupported feature.", filename)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exit codes 5 and 6 name the UNO exception class that was
|
||||||
|
// caught, not a cause: both cover a client mistake and a
|
||||||
|
// LibreOffice crash. Blame the client only when one of its
|
||||||
|
// inputs is actually implicated, since the server is the
|
||||||
|
// only remaining explanation otherwise. Password evidence
|
||||||
|
// outranks page ranges: a password failure aborts on import,
|
||||||
|
// before the export filter applies any page range.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1588.
|
||||||
|
if errors.Is(err, libreofficeapi.ErrUnoException) || errors.Is(err, libreofficeapi.ErrRuntimeException) {
|
||||||
|
protection := libreofficeapi.DetectPasswordProtection(inputPath)
|
||||||
|
|
||||||
|
var sentinel api.SentinelHttpError
|
||||||
|
switch {
|
||||||
|
case protection == libreofficeapi.PasswordProtectionRequired && options.Password == "":
|
||||||
|
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("The document '%s' is password-protected. Provide its password in the 'password' form field.", filename))
|
||||||
|
case protection == libreofficeapi.PasswordProtectionRequired:
|
||||||
|
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("The password for the document '%s' is incorrect. Check the 'password' form field.", filename))
|
||||||
|
case protection == libreofficeapi.PasswordProtectionNone && options.Password != "":
|
||||||
|
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("The document '%s' is not password-protected. Remove the 'password' form field.", filename))
|
||||||
|
case options.Password != "":
|
||||||
|
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice could not open the document '%s' with the given password. Check the 'password' form field, and omit it if the document is not password-protected.", filename))
|
||||||
|
case errors.Is(err, libreofficeapi.ErrUnoException) && options.PageRanges != "":
|
||||||
|
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice could not apply the page ranges '%s' to the document '%s'. Check the 'nativePageRanges' form field; valid values look like '1-4', '2' or '1,3,5-7'.", options.PageRanges, filename))
|
||||||
|
default:
|
||||||
|
sentinel = api.NewSentinelHttpError(http.StatusInternalServerError, fmt.Sprintf(unattributableFailureMessage, filename))
|
||||||
|
}
|
||||||
|
|
||||||
|
return api.WrapError(fmt.Errorf("convert to PDF: %w", err), sentinel)
|
||||||
|
}
|
||||||
|
|
||||||
return fmt.Errorf("convert to PDF: %w", err)
|
return fmt.Errorf("convert to PDF: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -441,12 +500,12 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.WatermarkStub(ctx, engine, watermark, outputPaths)
|
err = pdfengines.WatermarkStub(ctx, engine, watermarks, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("watermark PDFs: %w", err)
|
return fmt.Errorf("watermark PDFs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.StampStub(ctx, engine, stamp, outputPaths)
|
err = pdfengines.StampStub(ctx, engine, stamps, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("stamp PDFs: %w", err)
|
return fmt.Errorf("stamp PDFs: %w", err)
|
||||||
}
|
}
|
||||||
@@ -463,6 +522,11 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = pdfengines.OptimizeStub(ctx, engine, optimizeImages, imageQuality, outputPaths)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("optimize PDF images: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
needsConvertStub := !nativePdfFormats ||
|
needsConvertStub := !nativePdfFormats ||
|
||||||
(nativePdfFormats && splitMode != zeroValuedSplitMode) ||
|
(nativePdfFormats && splitMode != zeroValuedSplitMode) ||
|
||||||
(nativePdfFormats && hasPostProcessing)
|
(nativePdfFormats && hasPostProcessing)
|
||||||
@@ -503,7 +567,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)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user