mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 08:32:16 +01:00
Compare commits
136 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
190cad0ee2 | ||
|
|
1d0c9acb2a | ||
|
|
08181f8550 | ||
|
|
9ea0e82525 | ||
|
|
7967035981 | ||
|
|
3d891edee4 | ||
|
|
320ad62e7b | ||
|
|
27e70fde46 | ||
|
|
2a9bf6bf11 | ||
|
|
7f9c3e171c | ||
|
|
13c5b99962 | ||
|
|
6671b5e5d3 | ||
|
|
f9a01c9fb3 | ||
|
|
26d373854a | ||
|
|
93d0103585 | ||
|
|
c1cdcbdaab | ||
|
|
fe1b0020b8 | ||
|
|
b47b9f45d8 | ||
|
|
8c0ad887f2 | ||
|
|
45b9f32351 | ||
|
|
ace379a92c | ||
|
|
4998870723 | ||
|
|
64c28dd45e | ||
|
|
68e0f88d5b | ||
|
|
cc97cb7e59 | ||
|
|
200334197f | ||
|
|
ce9c48b2b0 | ||
|
|
05465b3a74 | ||
|
|
1c0ff24c4b | ||
|
|
8f711b0f99 | ||
|
|
430f95f79f | ||
|
|
259d80bb68 | ||
|
|
7a914fce65 | ||
|
|
a2a8c42457 | ||
|
|
4b192b1498 | ||
|
|
c204cadfc5 | ||
|
|
35f1a990a6 | ||
|
|
7729bd0590 | ||
|
|
2980ca97a3 | ||
|
|
bb95b9b7f3 | ||
|
|
62d98fef79 | ||
|
|
db51f9026d | ||
|
|
3187980ead | ||
|
|
eff9444294 | ||
|
|
999dd2db40 | ||
|
|
f44d59d514 | ||
|
|
092b526ab5 | ||
|
|
0b5eaff8d9 | ||
|
|
46e190970f | ||
|
|
55d19522a8 | ||
|
|
924576d3d4 | ||
|
|
405d8d1c2b | ||
|
|
1b1e100107 | ||
|
|
c3b4702424 | ||
|
|
866ae53436 | ||
|
|
53e6a0ecf8 | ||
|
|
eab3d2da12 | ||
|
|
3f01ca18d3 | ||
|
|
405f1069c0 | ||
|
|
f1d1215c7c | ||
|
|
9bf12e7cb9 | ||
|
|
b87a6d4dfb | ||
|
|
a80191a288 | ||
|
|
e4a43434dc | ||
|
|
4811a00543 | ||
|
|
cfb48d9af4 | ||
|
|
8b65315a09 | ||
|
|
20522fd1ac | ||
|
|
e28e2ebf0f | ||
|
|
15050a311b | ||
|
|
c99eb471f9 | ||
|
|
53c2f97008 | ||
|
|
61cadd0e6a | ||
|
|
813926cd47 | ||
|
|
f447e31ae2 | ||
|
|
00c6ed0387 | ||
|
|
cdf861bcfd | ||
|
|
5279d73928 | ||
|
|
e6d2a5640b | ||
|
|
4387a4f122 | ||
|
|
bf0d0a4f40 | ||
|
|
f8809b3943 | ||
|
|
9e5b05ad3c | ||
|
|
452a500955 | ||
|
|
1ab0117943 | ||
|
|
5be574081c | ||
|
|
6430bc6a5c | ||
|
|
e0eff40f74 | ||
|
|
385cbe6590 | ||
|
|
043b1588de | ||
|
|
e5d861def3 | ||
|
|
72bc6f895a | ||
|
|
7549a69f71 | ||
|
|
ebf0548d19 | ||
|
|
d3a65a587c | ||
|
|
8f7c1c98ad | ||
|
|
40d75882a2 | ||
|
|
bb91259829 | ||
|
|
0b33be17a4 | ||
|
|
3a78b89c97 | ||
|
|
c72be765b0 | ||
|
|
4e9f63004d | ||
|
|
08088c15f4 | ||
|
|
8625a4e899 | ||
|
|
06b2b2e10c | ||
|
|
bd6d92be9b | ||
|
|
ed22f1e5e6 | ||
|
|
8e3acc8d0a | ||
|
|
51afa5ba85 | ||
|
|
d67500ee05 | ||
|
|
0bec14c6ac | ||
|
|
3b0eb06991 | ||
|
|
410c1dfd7f | ||
|
|
b98378311f | ||
|
|
e6306e7c2f | ||
|
|
0663e5f92b | ||
|
|
21e300fcec | ||
|
|
7fb4c89832 | ||
|
|
1e26fdd35b | ||
|
|
19db80bc2e | ||
|
|
4ac493250c | ||
|
|
ec9d960538 | ||
|
|
5fa6603fa4 | ||
|
|
d81517bf26 | ||
|
|
9af0f2896c | ||
|
|
f619ce09d6 | ||
|
|
58c41dd01f | ||
|
|
ec3e415bf0 | ||
|
|
230cb8da2c | ||
|
|
59f96358c4 | ||
|
|
874e78c6cd | ||
|
|
caea81501d | ||
|
|
1578253fb1 | ||
|
|
fec6437c5d | ||
|
|
edff24913a | ||
|
|
5120b49639 |
76
.bruno/Chromium/Convert/HTML to PDF.bru
Normal file
76
.bruno/Chromium/Convert/HTML to PDF.bru
Normal file
@@ -0,0 +1,76 @@
|
||||
meta {
|
||||
name: HTML to PDF
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/chromium/convert/html
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../test/integration/testdata/page-1-html/index.html)
|
||||
~landscape: false
|
||||
~printBackground: false
|
||||
~scale: 1.0
|
||||
~singlePage: false
|
||||
~paperWidth: 8.5
|
||||
~paperHeight: 11
|
||||
~marginTop: 0.39
|
||||
~marginBottom: 0.39
|
||||
~marginLeft: 0.39
|
||||
~marginRight: 0.39
|
||||
~nativePageRanges: 1-2
|
||||
~preferCssPageSize: false
|
||||
~generateDocumentOutline: false
|
||||
~generateTaggedPdf: false
|
||||
~skipNetworkIdleEvent: false
|
||||
~failOnHttpStatusCodes: [499,599]
|
||||
~failOnResourceHttpStatusCodes: []
|
||||
~ignoreResourceHttpStatusDomains: []
|
||||
~failOnResourceLoadingFailed: false
|
||||
~failOnConsoleExceptions: false
|
||||
~waitDelay: 0s
|
||||
~waitWindowStatus:
|
||||
~waitForExpression:
|
||||
~waitForSelector:
|
||||
~cookies: [{"name":"my_cookie","value":"my_value","domain":"example.com"}]
|
||||
~userAgent:
|
||||
~extraHttpHeaders: {"X-Custom-Header":"value"}
|
||||
~emulatedMediaType: print
|
||||
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
|
||||
~omitBackground: false
|
||||
~splitMode: intervals
|
||||
~splitSpan: 1
|
||||
~splitUnify: false
|
||||
~pdfa: PDF/A-1b
|
||||
~pdfua: true
|
||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||
~embeds: @file(../test/integration/testdata/embed_2.xml)
|
||||
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
||||
~watermarkSource: text
|
||||
~watermarkExpression: CONFIDENTIAL
|
||||
~watermarkPages:
|
||||
~watermarkOptions: {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"}
|
||||
~stampSource: text
|
||||
~stampExpression: DRAFT
|
||||
~stampPages:
|
||||
~stampOptions: {"scale":"0.5 abs","rot":"45"}
|
||||
~rotateAngle: 90
|
||||
~rotatePages:
|
||||
}
|
||||
|
||||
headers {
|
||||
~Gotenberg-Output-Filename: my-file
|
||||
~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"}
|
||||
}
|
||||
77
.bruno/Chromium/Convert/Markdown to PDF.bru
Normal file
77
.bruno/Chromium/Convert/Markdown to PDF.bru
Normal file
@@ -0,0 +1,77 @@
|
||||
meta {
|
||||
name: Markdown to PDF
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/chromium/convert/markdown
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/page-1-markdown/index.html)
|
||||
files: @file(../../test/integration/testdata/page-1-markdown/page_1.md)
|
||||
~landscape: false
|
||||
~printBackground: false
|
||||
~scale: 1.0
|
||||
~singlePage: false
|
||||
~paperWidth: 8.5
|
||||
~paperHeight: 11
|
||||
~marginTop: 0.39
|
||||
~marginBottom: 0.39
|
||||
~marginLeft: 0.39
|
||||
~marginRight: 0.39
|
||||
~nativePageRanges: 1-2
|
||||
~preferCssPageSize: false
|
||||
~generateDocumentOutline: false
|
||||
~generateTaggedPdf: false
|
||||
~skipNetworkIdleEvent: false
|
||||
~failOnHttpStatusCodes: [499,599]
|
||||
~failOnResourceHttpStatusCodes: []
|
||||
~ignoreResourceHttpStatusDomains: []
|
||||
~failOnResourceLoadingFailed: false
|
||||
~failOnConsoleExceptions: false
|
||||
~waitDelay: 0s
|
||||
~waitWindowStatus:
|
||||
~waitForExpression:
|
||||
~waitForSelector:
|
||||
~cookies: [{"name":"my_cookie","value":"my_value","domain":"example.com"}]
|
||||
~userAgent:
|
||||
~extraHttpHeaders: {"X-Custom-Header":"value"}
|
||||
~emulatedMediaType: print
|
||||
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
|
||||
~omitBackground: false
|
||||
~splitMode: intervals
|
||||
~splitSpan: 1
|
||||
~splitUnify: false
|
||||
~pdfa: PDF/A-1b
|
||||
~pdfua: true
|
||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||
~embeds: @file(../test/integration/testdata/embed_2.xml)
|
||||
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
||||
~watermarkSource: text
|
||||
~watermarkExpression: CONFIDENTIAL
|
||||
~watermarkPages:
|
||||
~watermarkOptions: {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"}
|
||||
~stampSource: text
|
||||
~stampExpression: DRAFT
|
||||
~stampPages:
|
||||
~stampOptions: {"scale":"0.5 abs","rot":"45"}
|
||||
~rotateAngle: 90
|
||||
~rotatePages:
|
||||
}
|
||||
|
||||
headers {
|
||||
~Gotenberg-Output-Filename: my-file
|
||||
~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"}
|
||||
}
|
||||
76
.bruno/Chromium/Convert/URL to PDF.bru
Normal file
76
.bruno/Chromium/Convert/URL to PDF.bru
Normal file
@@ -0,0 +1,76 @@
|
||||
meta {
|
||||
name: URL to PDF
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/chromium/convert/url
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
url: https://example.com
|
||||
~landscape: false
|
||||
~printBackground: false
|
||||
~scale: 1.0
|
||||
~singlePage: false
|
||||
~paperWidth: 8.5
|
||||
~paperHeight: 11
|
||||
~marginTop: 0.39
|
||||
~marginBottom: 0.39
|
||||
~marginLeft: 0.39
|
||||
~marginRight: 0.39
|
||||
~nativePageRanges: 1-2
|
||||
~preferCssPageSize: false
|
||||
~generateDocumentOutline: false
|
||||
~generateTaggedPdf: false
|
||||
~skipNetworkIdleEvent: false
|
||||
~failOnHttpStatusCodes: [499,599]
|
||||
~failOnResourceHttpStatusCodes: []
|
||||
~ignoreResourceHttpStatusDomains: []
|
||||
~failOnResourceLoadingFailed: false
|
||||
~failOnConsoleExceptions: false
|
||||
~waitDelay: 0s
|
||||
~waitWindowStatus:
|
||||
~waitForExpression:
|
||||
~waitForSelector:
|
||||
~cookies: [{"name":"my_cookie","value":"my_value","domain":"example.com"}]
|
||||
~userAgent:
|
||||
~extraHttpHeaders: {"X-Custom-Header":"value"}
|
||||
~emulatedMediaType: print
|
||||
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
|
||||
~omitBackground: false
|
||||
~splitMode: intervals
|
||||
~splitSpan: 1
|
||||
~splitUnify: false
|
||||
~pdfa: PDF/A-1b
|
||||
~pdfua: true
|
||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||
~embeds: @file(../test/integration/testdata/embed_2.xml)
|
||||
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
||||
~watermarkSource: text
|
||||
~watermarkExpression: CONFIDENTIAL
|
||||
~watermarkPages:
|
||||
~watermarkOptions: {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"}
|
||||
~stampSource: text
|
||||
~stampExpression: DRAFT
|
||||
~stampPages:
|
||||
~stampOptions: {"scale":"0.5 abs","rot":"45"}
|
||||
~rotateAngle: 90
|
||||
~rotatePages:
|
||||
}
|
||||
|
||||
headers {
|
||||
~Gotenberg-Output-Filename: my-file
|
||||
~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"}
|
||||
}
|
||||
48
.bruno/Chromium/Screenshot/HTML Screenshot.bru
Normal file
48
.bruno/Chromium/Screenshot/HTML Screenshot.bru
Normal file
@@ -0,0 +1,48 @@
|
||||
meta {
|
||||
name: HTML Screenshot
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/chromium/screenshot/html
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/page-1-html/index.html)
|
||||
~width: 800
|
||||
~height: 600
|
||||
~clip: false
|
||||
~format: png
|
||||
~quality: 100
|
||||
~optimizeForSpeed: false
|
||||
~skipNetworkIdleEvent: false
|
||||
~failOnHttpStatusCodes: [499,599]
|
||||
~failOnResourceHttpStatusCodes: []
|
||||
~ignoreResourceHttpStatusDomains: []
|
||||
~failOnResourceLoadingFailed: false
|
||||
~failOnConsoleExceptions: false
|
||||
~waitDelay: 0s
|
||||
~waitWindowStatus:
|
||||
~waitForExpression:
|
||||
~waitForSelector:
|
||||
~cookies: [{"name":"my_cookie","value":"my_value","domain":"example.com"}]
|
||||
~userAgent:
|
||||
~extraHttpHeaders: {"X-Custom-Header":"value"}
|
||||
~emulatedMediaType: screen
|
||||
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
|
||||
~omitBackground: false
|
||||
~deviceScaleFactor: 1.0
|
||||
}
|
||||
|
||||
headers {
|
||||
~Gotenberg-Output-Filename: my-screenshot
|
||||
~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"}
|
||||
}
|
||||
49
.bruno/Chromium/Screenshot/Markdown Screenshot.bru
Normal file
49
.bruno/Chromium/Screenshot/Markdown Screenshot.bru
Normal file
@@ -0,0 +1,49 @@
|
||||
meta {
|
||||
name: Markdown Screenshot
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/chromium/screenshot/markdown
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/page-1-markdown/index.html)
|
||||
files: @file(../../test/integration/testdata/page-1-markdown/page_1.md)
|
||||
~width: 800
|
||||
~height: 600
|
||||
~clip: false
|
||||
~format: png
|
||||
~quality: 100
|
||||
~optimizeForSpeed: false
|
||||
~skipNetworkIdleEvent: false
|
||||
~failOnHttpStatusCodes: [499,599]
|
||||
~failOnResourceHttpStatusCodes: []
|
||||
~ignoreResourceHttpStatusDomains: []
|
||||
~failOnResourceLoadingFailed: false
|
||||
~failOnConsoleExceptions: false
|
||||
~waitDelay: 0s
|
||||
~waitWindowStatus:
|
||||
~waitForExpression:
|
||||
~waitForSelector:
|
||||
~cookies: [{"name":"my_cookie","value":"my_value","domain":"example.com"}]
|
||||
~userAgent:
|
||||
~extraHttpHeaders: {"X-Custom-Header":"value"}
|
||||
~emulatedMediaType: screen
|
||||
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
|
||||
~omitBackground: false
|
||||
~deviceScaleFactor: 1.0
|
||||
}
|
||||
|
||||
headers {
|
||||
~Gotenberg-Output-Filename: my-screenshot
|
||||
~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"}
|
||||
}
|
||||
48
.bruno/Chromium/Screenshot/URL Screenshot.bru
Normal file
48
.bruno/Chromium/Screenshot/URL Screenshot.bru
Normal file
@@ -0,0 +1,48 @@
|
||||
meta {
|
||||
name: URL Screenshot
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/chromium/screenshot/url
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
url: https://example.com
|
||||
~width: 800
|
||||
~height: 600
|
||||
~clip: false
|
||||
~format: png
|
||||
~quality: 100
|
||||
~optimizeForSpeed: false
|
||||
~skipNetworkIdleEvent: false
|
||||
~failOnHttpStatusCodes: [499,599]
|
||||
~failOnResourceHttpStatusCodes: []
|
||||
~ignoreResourceHttpStatusDomains: []
|
||||
~failOnResourceLoadingFailed: false
|
||||
~failOnConsoleExceptions: false
|
||||
~waitDelay: 0s
|
||||
~waitWindowStatus:
|
||||
~waitForExpression:
|
||||
~waitForSelector:
|
||||
~cookies: [{"name":"my_cookie","value":"my_value","domain":"example.com"}]
|
||||
~userAgent:
|
||||
~extraHttpHeaders: {"X-Custom-Header":"value"}
|
||||
~emulatedMediaType: screen
|
||||
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
|
||||
~omitBackground: false
|
||||
~deviceScaleFactor: 1.0
|
||||
}
|
||||
|
||||
headers {
|
||||
~Gotenberg-Output-Filename: my-screenshot
|
||||
~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"}
|
||||
}
|
||||
11
.bruno/Health & Info/Debug.bru
Normal file
11
.bruno/Health & Info/Debug.bru
Normal file
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: Debug
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/debug
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
11
.bruno/Health & Info/Health.bru
Normal file
11
.bruno/Health & Info/Health.bru
Normal file
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: Health
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/health
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
11
.bruno/Health & Info/Prometheus Metrics.bru
Normal file
11
.bruno/Health & Info/Prometheus Metrics.bru
Normal file
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: Prometheus Metrics
|
||||
type: http
|
||||
seq: 4
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/prometheus/metrics
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
11
.bruno/Health & Info/Version.bru
Normal file
11
.bruno/Health & Info/Version.bru
Normal file
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: Version
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/version
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
93
.bruno/LibreOffice/Convert to PDF.bru
Normal file
93
.bruno/LibreOffice/Convert to PDF.bru
Normal file
@@ -0,0 +1,93 @@
|
||||
meta {
|
||||
name: Convert to PDF
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/libreoffice/convert
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../test/integration/testdata/page_1.docx)
|
||||
~password:
|
||||
~landscape: false
|
||||
~nativePageRanges:
|
||||
~updateIndexes: false
|
||||
~exportFormFields: true
|
||||
~allowDuplicateFieldNames: false
|
||||
~exportBookmarks: true
|
||||
~exportBookmarksToPdfDestination: false
|
||||
~exportPlaceholders: false
|
||||
~exportNotes: false
|
||||
~exportNotesPages: false
|
||||
~exportOnlyNotesPages: false
|
||||
~exportNotesInMargin: false
|
||||
~convertOooTargetToPdfTarget: false
|
||||
~exportLinksRelativeFsys: false
|
||||
~exportHiddenSlides: false
|
||||
~skipEmptyPages: false
|
||||
~addOriginalDocumentAsStream: false
|
||||
~singlePageSheets: false
|
||||
~initialView: 0
|
||||
~initialPage: 1
|
||||
~magnification: 0
|
||||
~zoom: 100
|
||||
~pageLayout: 0
|
||||
~firstPageOnLeft: false
|
||||
~resizeWindowToInitialPage: false
|
||||
~centerWindow: false
|
||||
~openInFullScreenMode: false
|
||||
~displayPDFDocumentTitle: true
|
||||
~hideViewerMenubar: false
|
||||
~hideViewerToolbar: false
|
||||
~hideViewerWindowControls: false
|
||||
~useTransitionEffects: true
|
||||
~openBookmarkLevels: -1
|
||||
~losslessImageCompression: false
|
||||
~quality: 90
|
||||
~reduceImageResolution: false
|
||||
~maxImageResolution: 300
|
||||
~nativeWatermarkText:
|
||||
~nativeWatermarkColor: 0
|
||||
~nativeWatermarkFontHeight: 0
|
||||
~nativeWatermarkRotateAngle: 0
|
||||
~nativeWatermarkFontName:
|
||||
~nativeTiledWatermarkText:
|
||||
~nativePdfFormats: true
|
||||
~merge: false
|
||||
~flatten: false
|
||||
~splitMode: intervals
|
||||
~splitSpan: 1
|
||||
~splitUnify: false
|
||||
~pdfa: PDF/A-1b
|
||||
~pdfua: true
|
||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||
~embeds: @file(../test/integration/testdata/embed_2.xml)
|
||||
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
||||
~watermarkSource: text
|
||||
~watermarkExpression: CONFIDENTIAL
|
||||
~watermarkPages:
|
||||
~watermarkOptions: {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"}
|
||||
~stampSource: text
|
||||
~stampExpression: DRAFT
|
||||
~stampPages:
|
||||
~stampOptions: {"scale":"0.5 abs","rot":"45"}
|
||||
~rotateAngle: 90
|
||||
~rotatePages:
|
||||
}
|
||||
|
||||
headers {
|
||||
~Gotenberg-Output-Filename: my-file
|
||||
~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"}
|
||||
}
|
||||
24
.bruno/PDF Engines/Bookmarks/Read Bookmarks.bru
Normal file
24
.bruno/PDF Engines/Bookmarks/Read Bookmarks.bru
Normal file
@@ -0,0 +1,24 @@
|
||||
meta {
|
||||
name: Read Bookmarks
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/pdfengines/bookmarks/read
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/page_1.pdf)
|
||||
}
|
||||
|
||||
headers {
|
||||
~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"}
|
||||
}
|
||||
26
.bruno/PDF Engines/Bookmarks/Write Bookmarks.bru
Normal file
26
.bruno/PDF Engines/Bookmarks/Write Bookmarks.bru
Normal file
@@ -0,0 +1,26 @@
|
||||
meta {
|
||||
name: Write Bookmarks
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/pdfengines/bookmarks/write
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/page_1.pdf)
|
||||
bookmarks: [{"title":"Chapter 1","page":1,"children":[{"title":"Section 1.1","page":1}]}]
|
||||
}
|
||||
|
||||
headers {
|
||||
~Gotenberg-Output-Filename: with-bookmarks
|
||||
~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"}
|
||||
}
|
||||
27
.bruno/PDF Engines/Convert/Convert PDF.bru
Normal file
27
.bruno/PDF Engines/Convert/Convert PDF.bru
Normal file
@@ -0,0 +1,27 @@
|
||||
meta {
|
||||
name: Convert PDF
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/pdfengines/convert
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/page_1.pdf)
|
||||
pdfa: PDF/A-1b
|
||||
~pdfua: true
|
||||
}
|
||||
|
||||
headers {
|
||||
~Gotenberg-Output-Filename: converted
|
||||
~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"}
|
||||
}
|
||||
29
.bruno/PDF Engines/Embed/Embed Files.bru
Normal file
29
.bruno/PDF Engines/Embed/Embed Files.bru
Normal file
@@ -0,0 +1,29 @@
|
||||
meta {
|
||||
name: Embed Files
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/pdfengines/embed
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../test/integration/testdata/page_1.pdf)
|
||||
embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||
embeds: @file(../test/integration/testdata/embed_2.xml)
|
||||
embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
||||
~downloadFrom: [{"url":"https://example.com/attachment.xml","embedded":true}]
|
||||
}
|
||||
|
||||
headers {
|
||||
~Gotenberg-Output-Filename: with-embeds
|
||||
~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"}
|
||||
}
|
||||
27
.bruno/PDF Engines/Encrypt/Encrypt PDF.bru
Normal file
27
.bruno/PDF Engines/Encrypt/Encrypt PDF.bru
Normal file
@@ -0,0 +1,27 @@
|
||||
meta {
|
||||
name: Encrypt PDF
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/pdfengines/encrypt
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/page_1.pdf)
|
||||
userPassword: secret123
|
||||
~ownerPassword: owner456
|
||||
}
|
||||
|
||||
headers {
|
||||
~Gotenberg-Output-Filename: encrypted
|
||||
~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"}
|
||||
}
|
||||
25
.bruno/PDF Engines/Flatten/Flatten PDF.bru
Normal file
25
.bruno/PDF Engines/Flatten/Flatten PDF.bru
Normal file
@@ -0,0 +1,25 @@
|
||||
meta {
|
||||
name: Flatten PDF
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/pdfengines/flatten
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/page_1.pdf)
|
||||
}
|
||||
|
||||
headers {
|
||||
~Gotenberg-Output-Filename: flattened
|
||||
~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"}
|
||||
}
|
||||
44
.bruno/PDF Engines/Merge/Merge PDFs.bru
Normal file
44
.bruno/PDF Engines/Merge/Merge PDFs.bru
Normal file
@@ -0,0 +1,44 @@
|
||||
meta {
|
||||
name: Merge PDFs
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/pdfengines/merge
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/page_1.pdf)
|
||||
files: @file(../../test/integration/testdata/page_2.pdf)
|
||||
~flatten: false
|
||||
~autoIndexBookmarks: false
|
||||
~pdfa: PDF/A-1b
|
||||
~pdfua: true
|
||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||
~bookmarks: [{"title":"Page 1","page":1},{"title":"Page 2","page":2}]
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
~watermarkSource: text
|
||||
~watermarkExpression: CONFIDENTIAL
|
||||
~watermarkPages:
|
||||
~watermarkOptions: {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"}
|
||||
~stampSource: text
|
||||
~stampExpression: DRAFT
|
||||
~stampPages:
|
||||
~stampOptions: {"scale":"0.5 abs","rot":"45"}
|
||||
~rotateAngle: 90
|
||||
~rotatePages:
|
||||
}
|
||||
|
||||
headers {
|
||||
~Gotenberg-Output-Filename: merged
|
||||
~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"}
|
||||
}
|
||||
24
.bruno/PDF Engines/Metadata/Read Metadata.bru
Normal file
24
.bruno/PDF Engines/Metadata/Read Metadata.bru
Normal file
@@ -0,0 +1,24 @@
|
||||
meta {
|
||||
name: Read Metadata
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/pdfengines/metadata/read
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/page_1.pdf)
|
||||
}
|
||||
|
||||
headers {
|
||||
~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"}
|
||||
}
|
||||
26
.bruno/PDF Engines/Metadata/Write Metadata.bru
Normal file
26
.bruno/PDF Engines/Metadata/Write Metadata.bru
Normal file
@@ -0,0 +1,26 @@
|
||||
meta {
|
||||
name: Write Metadata
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/pdfengines/metadata/write
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/page_1.pdf)
|
||||
metadata: {"Author":"Bruno","Copyright":"Bruno","Creator":"Gotenberg","Keywords":["test"],"Marked":true,"Producer":"Gotenberg","Subject":"Test","Title":"Test Document","Trapped":"Unknown"}
|
||||
}
|
||||
|
||||
headers {
|
||||
~Gotenberg-Output-Filename: with-metadata
|
||||
~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"}
|
||||
}
|
||||
27
.bruno/PDF Engines/Rotate/Rotate PDF.bru
Normal file
27
.bruno/PDF Engines/Rotate/Rotate PDF.bru
Normal file
@@ -0,0 +1,27 @@
|
||||
meta {
|
||||
name: Rotate PDF
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/pdfengines/rotate
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/page_1.pdf)
|
||||
rotateAngle: 90
|
||||
~rotatePages: 1-2
|
||||
}
|
||||
|
||||
headers {
|
||||
~Gotenberg-Output-Filename: rotated
|
||||
~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"}
|
||||
}
|
||||
44
.bruno/PDF Engines/Split/Split PDF.bru
Normal file
44
.bruno/PDF Engines/Split/Split PDF.bru
Normal file
@@ -0,0 +1,44 @@
|
||||
meta {
|
||||
name: Split PDF
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/pdfengines/split
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/pages_3.pdf)
|
||||
splitMode: intervals
|
||||
splitSpan: 1
|
||||
~splitUnify: false
|
||||
~flatten: false
|
||||
~pdfa: PDF/A-1b
|
||||
~pdfua: true
|
||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
~watermarkSource: text
|
||||
~watermarkExpression: CONFIDENTIAL
|
||||
~watermarkPages:
|
||||
~watermarkOptions: {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"}
|
||||
~stampSource: text
|
||||
~stampExpression: DRAFT
|
||||
~stampPages:
|
||||
~stampOptions: {"scale":"0.5 abs","rot":"45"}
|
||||
~rotateAngle: 90
|
||||
~rotatePages:
|
||||
}
|
||||
|
||||
headers {
|
||||
~Gotenberg-Output-Filename: split
|
||||
~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"}
|
||||
}
|
||||
31
.bruno/PDF Engines/Stamp/Stamp PDF.bru
Normal file
31
.bruno/PDF Engines/Stamp/Stamp PDF.bru
Normal file
@@ -0,0 +1,31 @@
|
||||
meta {
|
||||
name: Stamp PDF
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/pdfengines/stamp
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/page_1.pdf)
|
||||
stampSource: text
|
||||
stampExpression: APPROVED
|
||||
~stampPages: 1-2
|
||||
~stampOptions: {"font":"Helvetica","fontSize":"48","color":"#00FF00","opacity":"0.5","rotation":"0"}
|
||||
~stamp: @file(../../test/integration/testdata/watermark.png)
|
||||
~downloadFrom: [{"url":"https://example.com/stamp.png","field":"stamp"}]
|
||||
}
|
||||
|
||||
headers {
|
||||
~Gotenberg-Output-Filename: stamped
|
||||
~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"}
|
||||
}
|
||||
31
.bruno/PDF Engines/Watermark/Watermark PDF.bru
Normal file
31
.bruno/PDF Engines/Watermark/Watermark PDF.bru
Normal file
@@ -0,0 +1,31 @@
|
||||
meta {
|
||||
name: Watermark PDF
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/pdfengines/watermark
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/page_1.pdf)
|
||||
watermarkSource: text
|
||||
watermarkExpression: CONFIDENTIAL
|
||||
~watermarkPages: 1-2
|
||||
~watermarkOptions: {"font":"Helvetica","fontSize":"48","color":"#FF0000","opacity":"0.3","rotation":"45"}
|
||||
~watermark: @file(../../test/integration/testdata/watermark.png)
|
||||
~downloadFrom: [{"url":"https://example.com/watermark.png","field":"watermark"}]
|
||||
}
|
||||
|
||||
headers {
|
||||
~Gotenberg-Output-Filename: watermarked
|
||||
~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"}
|
||||
}
|
||||
65
.bruno/README.md
Normal file
65
.bruno/README.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Bruno API Collection
|
||||
|
||||
[Bruno](https://www.usebruno.com/) collection mirroring every Gotenberg route. Update the collection when adding or modifying a route.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
.bruno/
|
||||
├── bruno.json # Collection config
|
||||
├── collection.bru # Collection-level defaults (Gotenberg-Trace header)
|
||||
├── environments/
|
||||
│ ├── Local.bru # baseUrl: http://localhost:3000
|
||||
│ └── Demo.bru # baseUrl: https://demo.gotenberg.dev
|
||||
├── Health & Info/ # GET routes
|
||||
├── Chromium/Convert/ # POST routes grouped by module
|
||||
├── Chromium/Screenshot/
|
||||
├── LibreOffice/
|
||||
└── PDF Engines/<Feature>/ # One folder per feature (Merge, Split, Rotate, ...)
|
||||
```
|
||||
|
||||
## `.bru` file format
|
||||
|
||||
```bru
|
||||
meta {
|
||||
name: <Human-readable name>
|
||||
type: http
|
||||
seq: <order within folder>
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/<path>
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/<file>)
|
||||
<mandatoryField>: <value>
|
||||
~<optionalField>: <value>
|
||||
}
|
||||
|
||||
headers {
|
||||
~Gotenberg-Output-Filename: <name>
|
||||
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
|
||||
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
|
||||
~Gotenberg-Webhook-Method: POST
|
||||
~Gotenberg-Webhook-Error-Method: POST
|
||||
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
|
||||
}
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- Mandatory fields have no prefix. Optional fields use `~` (disabled by default in Bruno).
|
||||
- File references use relative paths to `test/integration/testdata/`.
|
||||
- Webhook and output filename headers appear on every POST route as optional (`~`).
|
||||
- One `.bru` file per request. For routes with read/write variants (e.g., bookmarks, metadata), create separate files in the same folder.
|
||||
|
||||
## Checklist
|
||||
|
||||
1. Create or update the `.bru` file in the matching folder under `.bruno/`.
|
||||
2. Include all form fields from the route handler. Check `FormData*` calls in the route function.
|
||||
3. For file upload fields (`files`, `watermark`, `stamp`, `embeds`), use `@file(...)` with a suitable test file.
|
||||
4. Verify the URL path matches the route's `Path` field exactly.
|
||||
5. For new module folders, keep the naming consistent (e.g., `PDF Engines/Rotate/`).
|
||||
6
.bruno/bruno.json
Normal file
6
.bruno/bruno.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "Gotenberg",
|
||||
"type": "collection",
|
||||
"ignore": ["node_modules", ".git"]
|
||||
}
|
||||
3
.bruno/collection.bru
Normal file
3
.bruno/collection.bru
Normal file
@@ -0,0 +1,3 @@
|
||||
headers {
|
||||
Gotenberg-Trace: {{traceId}}
|
||||
}
|
||||
4
.bruno/environments/Demo.bru
Normal file
4
.bruno/environments/Demo.bru
Normal file
@@ -0,0 +1,4 @@
|
||||
vars {
|
||||
baseUrl: https://demo.gotenberg.dev
|
||||
traceId: bruno-demo
|
||||
}
|
||||
4
.bruno/environments/Local.bru
Normal file
4
.bruno/environments/Local.bru
Normal file
@@ -0,0 +1,4 @@
|
||||
vars {
|
||||
baseUrl: http://localhost:3000
|
||||
traceId: bruno-local
|
||||
}
|
||||
3
.env
3
.env
@@ -2,6 +2,5 @@ GOTENBERG_VERSION=snapshot
|
||||
DOCKER_REGISTRY=gotenberg
|
||||
DOCKER_REPOSITORY=gotenberg
|
||||
DOCKERFILE=build/Dockerfile
|
||||
DOCKERFILE_CLOUDRUN=build/Dockerfile.cloudrun
|
||||
DOCKERFILE_AWS_LAMBDA=build/Dockerfile.aws-lambda
|
||||
DOCKER_BUILD_CONTEXT='.'
|
||||
TARGET=gotenberg
|
||||
|
||||
65
.github/actions/build-test-push/action.yml
vendored
65
.github/actions/build-test-push/action.yml
vendored
@@ -29,14 +29,32 @@ inputs:
|
||||
|
||||
outputs:
|
||||
tags:
|
||||
description: Comma separated list of tag
|
||||
description: Comma separated list of full variant tags
|
||||
value: ${{ steps.build.outputs.tags }}
|
||||
tags_chromium:
|
||||
description: Comma separated list of Chromium variant tags
|
||||
value: ${{ steps.build.outputs.tags_chromium }}
|
||||
tags_libreoffice:
|
||||
description: Comma separated list of LibreOffice variant tags
|
||||
value: ${{ steps.build.outputs.tags_libreoffice }}
|
||||
tags_cloud_run:
|
||||
description: Comma separated list of Cloud Run tags (linux/amd64 only)
|
||||
description: Comma separated list of Cloud Run full variant tags (linux/amd64 only)
|
||||
value: ${{ steps.build.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium:
|
||||
description: Comma separated list of Cloud Run Chromium variant tags (linux/amd64 only)
|
||||
value: ${{ steps.build.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice:
|
||||
description: Comma separated list of Cloud Run LibreOffice variant tags (linux/amd64 only)
|
||||
value: ${{ steps.build.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda:
|
||||
description: Comma separated list of AWS Lambda tags (linux/amd64 and linux/arm64 only)
|
||||
description: Comma separated list of AWS Lambda full variant tags (linux/amd64 and linux/arm64 only)
|
||||
value: ${{ steps.build.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium:
|
||||
description: Comma separated list of AWS Lambda Chromium variant tags (linux/amd64 and linux/arm64 only)
|
||||
value: ${{ steps.build.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice:
|
||||
description: Comma separated list of AWS Lambda LibreOffice variant tags (linux/amd64 and linux/arm64 only)
|
||||
value: ${{ steps.build.outputs.tags_aws_lambda_libreoffice }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -48,7 +66,7 @@ runs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: inputs.docker_hub_username != ''
|
||||
@@ -60,34 +78,53 @@ runs:
|
||||
- name: Build ${{ inputs.platform }}
|
||||
id: build
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
INPUT_PLATFORM: ${{ inputs.platform }}
|
||||
INPUT_ALTERNATE_REPOSITORY: ${{ inputs.alternate_repository }}
|
||||
INPUT_DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
.github/actions/build-test-push/build.sh \
|
||||
--version "${{ inputs.version }}" \
|
||||
--platform "${{ inputs.platform }}" \
|
||||
--alternate-repository "${{ inputs.alternate_repository }}" \
|
||||
--dry-run "${{ inputs.dry_run }}"
|
||||
--version "$INPUT_VERSION" \
|
||||
--platform "$INPUT_PLATFORM" \
|
||||
--alternate-repository "$INPUT_ALTERNATE_REPOSITORY" \
|
||||
--dry-run "$INPUT_DRY_RUN"
|
||||
|
||||
- name: Run integration tests
|
||||
if: inputs.skip_integrations_tests != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
INPUT_PLATFORM: ${{ inputs.platform }}
|
||||
INPUT_ALTERNATE_REPOSITORY: ${{ inputs.alternate_repository }}
|
||||
INPUT_DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
.github/actions/build-test-push/test.sh \
|
||||
--version "${{ inputs.version }}" \
|
||||
--platform "${{ inputs.platform }}" \
|
||||
--alternate-repository "${{ inputs.alternate_repository }}" \
|
||||
--dry-run "${{ inputs.dry_run }}"
|
||||
--version "$INPUT_VERSION" \
|
||||
--platform "$INPUT_PLATFORM" \
|
||||
--alternate-repository "$INPUT_ALTERNATE_REPOSITORY" \
|
||||
--dry-run "$INPUT_DRY_RUN"
|
||||
|
||||
- name: Push
|
||||
if: inputs.docker_hub_username != ''
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_TAGS: ${{ steps.build.outputs.tags }},${{ steps.build.outputs.tags_chromium }},${{ steps.build.outputs.tags_libreoffice }},${{ steps.build.outputs.tags_cloud_run }},${{ steps.build.outputs.tags_cloud_run_chromium }},${{ steps.build.outputs.tags_cloud_run_libreoffice }},${{ steps.build.outputs.tags_aws_lambda }},${{ steps.build.outputs.tags_aws_lambda_chromium }},${{ steps.build.outputs.tags_aws_lambda_libreoffice }}
|
||||
INPUT_DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
.github/actions/build-test-push/push.sh \
|
||||
--tags "${{ steps.build.outputs.tags }},${{ steps.build.outputs.tags_cloud_run }},${{ steps.build.outputs.tags_aws_lambda }}" \
|
||||
--dry-run "${{ inputs.dry_run }}"
|
||||
--tags "$INPUT_TAGS" \
|
||||
--dry-run "$INPUT_DRY_RUN"
|
||||
|
||||
- name: Outputs
|
||||
shell: bash
|
||||
run: |
|
||||
echo "tags=${{ steps.build.outputs.tags }}"
|
||||
echo "tags_chromium=${{ steps.build.outputs.tags_chromium }}"
|
||||
echo "tags_libreoffice=${{ steps.build.outputs.tags_libreoffice }}"
|
||||
echo "tags_cloud_run=${{ steps.build.outputs.tags_cloud_run }}"
|
||||
echo "tags_cloud_run_chromium=${{ steps.build.outputs.tags_cloud_run_chromium }}"
|
||||
echo "tags_cloud_run_libreoffice=${{ steps.build.outputs.tags_cloud_run_libreoffice }}"
|
||||
echo "tags_aws_lambda=${{ steps.build.outputs.tags_aws_lambda }}"
|
||||
echo "tags_aws_lambda_chromium=${{ steps.build.outputs.tags_aws_lambda_chromium }}"
|
||||
echo "tags_aws_lambda_libreoffice=${{ steps.build.outputs.tags_aws_lambda_libreoffice }}"
|
||||
|
||||
164
.github/actions/build-test-push/build.sh
vendored
164
.github/actions/build-test-push/build.sh
vendored
@@ -55,8 +55,14 @@ fi
|
||||
|
||||
# Build tags arrays.
|
||||
tags=()
|
||||
tags_chromium=()
|
||||
tags_libreoffice=()
|
||||
tags_cloud_run=()
|
||||
tags_cloud_run_chromium=()
|
||||
tags_cloud_run_libreoffice=()
|
||||
tags_aws_lambda=()
|
||||
tags_aws_lambda_chromium=()
|
||||
tags_aws_lambda_libreoffice=()
|
||||
|
||||
IFS='/' read -ra arch <<< "$platform"
|
||||
IFS='.' read -ra semver <<< "$version"
|
||||
@@ -69,55 +75,95 @@ if [ "${#semver[@]}" -eq 3 ]; then
|
||||
minor="${semver[1]}"
|
||||
patch="${semver[2]}"
|
||||
|
||||
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-${arch[1]}")
|
||||
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major-${arch[1]}")
|
||||
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor-${arch[1]}")
|
||||
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor.$patch-${arch[1]}")
|
||||
for suffix in "latest" "$major" "$major.$minor" "$major.$minor.$patch"; do
|
||||
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-${arch[1]}")
|
||||
tags_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-chromium-${arch[1]}")
|
||||
tags_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-libreoffice-${arch[1]}")
|
||||
done
|
||||
|
||||
if [ "$platform" = "linux/amd64" ]; then
|
||||
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-cloudrun")
|
||||
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major-cloudrun")
|
||||
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor-cloudrun")
|
||||
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor.$patch-cloudrun")
|
||||
for suffix in "latest" "$major" "$major.$minor" "$major.$minor.$patch"; do
|
||||
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-cloudrun")
|
||||
tags_cloud_run_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-chromium-cloudrun")
|
||||
tags_cloud_run_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-libreoffice-cloudrun")
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then
|
||||
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-aws-lambda-${arch[1]}")
|
||||
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major-aws-lambda-${arch[1]}")
|
||||
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor-aws-lambda-${arch[1]}")
|
||||
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor.$patch-aws-lambda-${arch[1]}")
|
||||
for suffix in "latest" "$major" "$major.$minor" "$major.$minor.$patch"; do
|
||||
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-aws-lambda-${arch[1]}")
|
||||
tags_aws_lambda_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-chromium-aws-lambda-${arch[1]}")
|
||||
tags_aws_lambda_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-libreoffice-aws-lambda-${arch[1]}")
|
||||
done
|
||||
fi
|
||||
else
|
||||
echo
|
||||
echo "Non-semver version detected, fallback to $version"
|
||||
|
||||
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-${arch[1]}")
|
||||
tags_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-chromium-${arch[1]}")
|
||||
tags_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-libreoffice-${arch[1]}")
|
||||
|
||||
if [ "$platform" = "linux/amd64" ]; then
|
||||
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-cloudrun")
|
||||
tags_cloud_run_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-chromium-cloudrun")
|
||||
tags_cloud_run_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-libreoffice-cloudrun")
|
||||
fi
|
||||
|
||||
if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then
|
||||
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-aws-lambda-${arch[1]}")
|
||||
tags_aws_lambda_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-chromium-aws-lambda-${arch[1]}")
|
||||
tags_aws_lambda_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-libreoffice-aws-lambda-${arch[1]}")
|
||||
fi
|
||||
fi
|
||||
|
||||
tags_flags=()
|
||||
tags_chromium_flags=()
|
||||
tags_libreoffice_flags=()
|
||||
tags_cloud_run_flags=()
|
||||
tags_cloud_run_chromium_flags=()
|
||||
tags_cloud_run_libreoffice_flags=()
|
||||
tags_aws_lambda_flags=()
|
||||
tags_aws_lambda_chromium_flags=()
|
||||
tags_aws_lambda_libreoffice_flags=()
|
||||
|
||||
echo "Will use the following tags:"
|
||||
for tag in "${tags[@]}"; do
|
||||
tags_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
for tag in "${tags_chromium[@]}"; do
|
||||
tags_chromium_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
for tag in "${tags_libreoffice[@]}"; do
|
||||
tags_libreoffice_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
for tag in "${tags_cloud_run[@]}"; do
|
||||
tags_cloud_run_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
for tag in "${tags_cloud_run_chromium[@]}"; do
|
||||
tags_cloud_run_chromium_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
for tag in "${tags_cloud_run_libreoffice[@]}"; do
|
||||
tags_cloud_run_libreoffice_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
for tag in "${tags_aws_lambda[@]}"; do
|
||||
tags_aws_lambda_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
for tag in "${tags_aws_lambda_chromium[@]}"; do
|
||||
tags_aws_lambda_chromium_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
for tag in "${tags_aws_lambda_libreoffice[@]}"; do
|
||||
tags_aws_lambda_libreoffice_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
echo
|
||||
|
||||
# Build images.
|
||||
@@ -145,7 +191,9 @@ join() {
|
||||
|
||||
no_arch_tag="$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version"
|
||||
|
||||
# Full variant.
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
@@ -155,30 +203,102 @@ cmd="docker buildx build \
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
|
||||
# Chromium only variant.
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg-chromium \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
${tags_chromium_flags[*]} \
|
||||
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
|
||||
# LibreOffice only variant.
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg-libreoffice \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
${tags_libreoffice_flags[*]} \
|
||||
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
|
||||
# Cloud Run variants (amd64 only).
|
||||
if [ "$platform" = "linux/amd64" ]; then
|
||||
cmd="docker build \
|
||||
--build-arg DOCKER_REGISTRY=$DOCKER_REGISTRY \
|
||||
--build-arg DOCKER_REPOSITORY=$DOCKER_REPOSITORY \
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg-cloudrun \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
${tags_cloud_run_flags[*]} \
|
||||
-f $DOCKERFILE_CLOUDRUN $DOCKER_BUILD_CONTEXT
|
||||
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg-cloudrun-chromium \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
${tags_cloud_run_chromium_flags[*]} \
|
||||
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg-cloudrun-libreoffice \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
${tags_cloud_run_libreoffice_flags[*]} \
|
||||
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
fi
|
||||
|
||||
# AWS Lambda variants (amd64 + arm64 only).
|
||||
if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then
|
||||
cmd="docker build \
|
||||
--build-arg DOCKER_REGISTRY=$DOCKER_REGISTRY \
|
||||
--build-arg DOCKER_REPOSITORY=$DOCKER_REPOSITORY \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
${tags_aws_lambda_flags[*]} \
|
||||
-f $DOCKERFILE_AWS_LAMBDA $DOCKER_BUILD_CONTEXT
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg-aws-lambda \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
${tags_aws_lambda_flags[*]} \
|
||||
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg-aws-lambda-chromium \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
${tags_aws_lambda_chromium_flags[*]} \
|
||||
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg-aws-lambda-libreoffice \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
${tags_aws_lambda_libreoffice_flags[*]} \
|
||||
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
fi
|
||||
|
||||
echo "✅ Done!"
|
||||
echo "tags=$(join "," "${tags[@]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "tags_chromium=$(join "," "${tags_chromium[@]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "tags_libreoffice=$(join "," "${tags_libreoffice[@]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "tags_cloud_run=$(join "," "${tags_cloud_run[@]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "tags_cloud_run_chromium=$(join "," "${tags_cloud_run_chromium[@]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "tags_cloud_run_libreoffice=$(join "," "${tags_cloud_run_libreoffice[@]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "tags_aws_lambda=$(join "," "${tags_aws_lambda[@]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "tags_aws_lambda_chromium=$(join "," "${tags_aws_lambda_chromium[@]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "tags_aws_lambda_libreoffice=$(join "," "${tags_aws_lambda_libreoffice[@]}")" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
|
||||
9
.github/actions/clean/action.yml
vendored
9
.github/actions/clean/action.yml
vendored
@@ -23,9 +23,12 @@ runs:
|
||||
env:
|
||||
DOCKERHUB_USERNAME: ${{ inputs.docker_hub_username }}
|
||||
DOCKERHUB_TOKEN: ${{ inputs.docker_hub_password }}
|
||||
INPUT_TAGS: ${{ inputs.tags }}
|
||||
INPUT_SNAPSHOT_VERSION: ${{ inputs.snapshot_version }}
|
||||
INPUT_DRY_RUN: ${{ inputs.dry_run }}
|
||||
shell: bash
|
||||
run: |
|
||||
.github/actions/clean/clean.sh \
|
||||
--tags "${{ inputs.tags }}" \
|
||||
--snapshot-version "${{ inputs.snapshot_version }}" \
|
||||
--dry-run "${{ inputs.dry_run }}"
|
||||
--tags "$INPUT_TAGS" \
|
||||
--snapshot-version "$INPUT_SNAPSHOT_VERSION" \
|
||||
--dry-run "$INPUT_DRY_RUN"
|
||||
|
||||
12
.github/actions/merge/action.yml
vendored
12
.github/actions/merge/action.yml
vendored
@@ -31,7 +31,7 @@ runs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
@@ -41,8 +41,12 @@ runs:
|
||||
|
||||
- name: Merge
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_TAGS: ${{ inputs.tags }}
|
||||
INPUT_ALTERNATE_REGISTRY: ${{ inputs.alternate_registry }}
|
||||
INPUT_DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
.github/actions/merge/merge.sh \
|
||||
--tags "${{ inputs.tags }}" \
|
||||
--alternate-registry "${{ inputs.alternate_registry }}" \
|
||||
--dry-run "${{ inputs.dry_run }}"
|
||||
--tags "$INPUT_TAGS" \
|
||||
--alternate-registry "$INPUT_ALTERNATE_REGISTRY" \
|
||||
--dry-run "$INPUT_DRY_RUN"
|
||||
|
||||
2
.github/dependabot.yml
vendored
2
.github/dependabot.yml
vendored
@@ -16,3 +16,5 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
62
.github/workflows/continuous-delivery.yml
vendored
62
.github/workflows/continuous-delivery.yml
vendored
@@ -13,8 +13,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tags: ${{ steps.build_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -34,8 +40,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tags: ${{ steps.build_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -55,8 +67,14 @@ jobs:
|
||||
runs-on: ubuntu-24.04-ppc64le
|
||||
outputs:
|
||||
tags: ${{ steps.build_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -76,8 +94,14 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
outputs:
|
||||
tags: ${{ steps.build_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -97,8 +121,14 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
outputs:
|
||||
tags: ${{ steps.build_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -132,7 +162,20 @@ jobs:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_ppc64le.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }}"
|
||||
alternate_registry: thecodingmachine
|
||||
|
||||
- name: Merge Chromium
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.release_amd64.outputs.tags_chromium }},${{ needs.release_386.outputs.tags_chromium }},${{ needs.release_ppc64le.outputs.tags_chromium }},${{ needs.release_arm64.outputs.tags_chromium }},${{ needs.release_arm_v7.outputs.tags_chromium }}"
|
||||
|
||||
- name: Merge LibreOffice
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.release_amd64.outputs.tags_libreoffice }},${{ needs.release_386.outputs.tags_libreoffice }},${{ needs.release_ppc64le.outputs.tags_libreoffice }},${{ needs.release_arm64.outputs.tags_libreoffice }},${{ needs.release_arm_v7.outputs.tags_libreoffice }}"
|
||||
|
||||
- name: Merge AWS Lambda
|
||||
uses: ./.github/actions/merge
|
||||
@@ -140,11 +183,24 @@ jobs:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.release_amd64.outputs.tags_aws_lambda }},${{ needs.release_arm64.outputs.tags_aws_lambda }}"
|
||||
alternate_registry: thecodingmachine
|
||||
|
||||
- name: Merge AWS Lambda Chromium
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.release_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.release_arm64.outputs.tags_aws_lambda_chromium }}"
|
||||
|
||||
- name: Merge AWS Lambda LibreOffice
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.release_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.release_arm64.outputs.tags_aws_lambda_libreoffice }}"
|
||||
|
||||
- name: Clean
|
||||
uses: ./.github/actions/clean
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_ppc64le.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }},${{ needs.release_amd64.outputs.tags_aws_lambda }},${{ needs.release_arm64.outputs.tags_aws_lambda }}"
|
||||
tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_ppc64le.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }},${{ needs.release_amd64.outputs.tags_chromium }},${{ needs.release_386.outputs.tags_chromium }},${{ needs.release_ppc64le.outputs.tags_chromium }},${{ needs.release_arm64.outputs.tags_chromium }},${{ needs.release_arm_v7.outputs.tags_chromium }},${{ needs.release_amd64.outputs.tags_libreoffice }},${{ needs.release_386.outputs.tags_libreoffice }},${{ needs.release_ppc64le.outputs.tags_libreoffice }},${{ needs.release_arm64.outputs.tags_libreoffice }},${{ needs.release_arm_v7.outputs.tags_libreoffice }},${{ needs.release_amd64.outputs.tags_aws_lambda }},${{ needs.release_arm64.outputs.tags_aws_lambda }},${{ needs.release_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.release_arm64.outputs.tags_aws_lambda_chromium }},${{ needs.release_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.release_arm64.outputs.tags_aws_lambda_libreoffice }}"
|
||||
|
||||
124
.github/workflows/continuous-integration.yml
vendored
124
.github/workflows/continuous-integration.yml
vendored
@@ -46,7 +46,7 @@ jobs:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm i
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Run linters
|
||||
run: make lint-prettier
|
||||
@@ -77,8 +77,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -101,8 +107,14 @@ jobs:
|
||||
runs-on: ubuntu-24.04-ppc64le
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -125,8 +137,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -149,8 +167,14 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -173,8 +197,14 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -232,6 +262,20 @@ jobs:
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.snapshot_amd64.outputs.tags }},${{ needs.snapshot_386.outputs.tags }},${{ needs.snapshot_ppc64le.outputs.tags }},${{ needs.snapshot_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }}"
|
||||
|
||||
- name: Merge Chromium
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.snapshot_amd64.outputs.tags_chromium }},${{ needs.snapshot_386.outputs.tags_chromium }},${{ needs.snapshot_ppc64le.outputs.tags_chromium }},${{ needs.snapshot_arm64.outputs.tags_chromium }},${{ needs.snapshot_arm_v7.outputs.tags_chromium }}"
|
||||
|
||||
- name: Merge LibreOffice
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.snapshot_amd64.outputs.tags_libreoffice }},${{ needs.snapshot_386.outputs.tags_libreoffice }},${{ needs.snapshot_ppc64le.outputs.tags_libreoffice }},${{ needs.snapshot_arm64.outputs.tags_libreoffice }},${{ needs.snapshot_arm_v7.outputs.tags_libreoffice }}"
|
||||
|
||||
- name: Merge AWS Lambda
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
@@ -239,12 +283,26 @@ jobs:
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.snapshot_amd64.outputs.tags_aws_lambda }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda }}"
|
||||
|
||||
- name: Merge AWS Lambda Chromium
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.snapshot_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda_chromium }}"
|
||||
|
||||
- name: Merge AWS Lambda LibreOffice
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.snapshot_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda_libreoffice }}"
|
||||
|
||||
- name: Clean
|
||||
uses: ./.github/actions/clean
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.snapshot_amd64.outputs.tags }},${{ needs.snapshot_386.outputs.tags }},${{ needs.snapshot_ppc64le.outputs.tags }},${{ needs.snapshot_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }},${{ needs.snapshot_amd64.outputs.tags_aws_lambda }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda }}"
|
||||
tags: "${{ needs.snapshot_amd64.outputs.tags }},${{ needs.snapshot_386.outputs.tags }},${{ needs.snapshot_ppc64le.outputs.tags }},${{ needs.snapshot_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }},${{ needs.snapshot_amd64.outputs.tags_chromium }},${{ needs.snapshot_386.outputs.tags_chromium }},${{ needs.snapshot_ppc64le.outputs.tags_chromium }},${{ needs.snapshot_arm64.outputs.tags_chromium }},${{ needs.snapshot_arm_v7.outputs.tags_chromium }},${{ needs.snapshot_amd64.outputs.tags_libreoffice }},${{ needs.snapshot_386.outputs.tags_libreoffice }},${{ needs.snapshot_ppc64le.outputs.tags_libreoffice }},${{ needs.snapshot_arm64.outputs.tags_libreoffice }},${{ needs.snapshot_arm_v7.outputs.tags_libreoffice }},${{ needs.snapshot_amd64.outputs.tags_aws_lambda }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda }},${{ needs.snapshot_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda_chromium }},${{ needs.snapshot_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda_libreoffice }}"
|
||||
|
||||
edge_amd64:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
@@ -254,8 +312,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -277,8 +341,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -300,8 +370,14 @@ jobs:
|
||||
runs-on: ubuntu-24.04-ppc64le
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -323,8 +399,14 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -346,8 +428,14 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -380,7 +468,20 @@ jobs:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_ppc64le.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }}"
|
||||
alternate_registry: thecodingmachine
|
||||
|
||||
- name: Merge Chromium
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.edge_amd64.outputs.tags_chromium }},${{ needs.edge_386.outputs.tags_chromium }},${{ needs.edge_ppc64le.outputs.tags_chromium }},${{ needs.edge_arm64.outputs.tags_chromium }},${{ needs.edge_arm_v7.outputs.tags_chromium }}"
|
||||
|
||||
- name: Merge LibreOffice
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.edge_amd64.outputs.tags_libreoffice }},${{ needs.edge_386.outputs.tags_libreoffice }},${{ needs.edge_ppc64le.outputs.tags_libreoffice }},${{ needs.edge_arm64.outputs.tags_libreoffice }},${{ needs.edge_arm_v7.outputs.tags_libreoffice }}"
|
||||
|
||||
- name: Merge AWS Lambda
|
||||
uses: ./.github/actions/merge
|
||||
@@ -388,11 +489,24 @@ jobs:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.edge_amd64.outputs.tags_aws_lambda }},${{ needs.edge_arm64.outputs.tags_aws_lambda }}"
|
||||
alternate_registry: thecodingmachine
|
||||
|
||||
- name: Merge AWS Lambda Chromium
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.edge_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.edge_arm64.outputs.tags_aws_lambda_chromium }}"
|
||||
|
||||
- name: Merge AWS Lambda LibreOffice
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.edge_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.edge_arm64.outputs.tags_aws_lambda_libreoffice }}"
|
||||
|
||||
- name: Clean
|
||||
uses: ./.github/actions/clean
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_ppc64le.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }},${{ needs.edge_amd64.outputs.tags_aws_lambda }},${{ needs.edge_arm64.outputs.tags_aws_lambda }}"
|
||||
tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_ppc64le.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }},${{ needs.edge_amd64.outputs.tags_chromium }},${{ needs.edge_386.outputs.tags_chromium }},${{ needs.edge_ppc64le.outputs.tags_chromium }},${{ needs.edge_arm64.outputs.tags_chromium }},${{ needs.edge_arm_v7.outputs.tags_chromium }},${{ needs.edge_amd64.outputs.tags_libreoffice }},${{ needs.edge_386.outputs.tags_libreoffice }},${{ needs.edge_ppc64le.outputs.tags_libreoffice }},${{ needs.edge_arm64.outputs.tags_libreoffice }},${{ needs.edge_arm_v7.outputs.tags_libreoffice }},${{ needs.edge_amd64.outputs.tags_aws_lambda }},${{ needs.edge_arm64.outputs.tags_aws_lambda }},${{ needs.edge_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.edge_arm64.outputs.tags_aws_lambda_chromium }},${{ needs.edge_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.edge_arm64.outputs.tags_aws_lambda_libreoffice }}"
|
||||
|
||||
@@ -24,13 +24,14 @@ linters:
|
||||
- ineffassign
|
||||
- misspell
|
||||
- prealloc
|
||||
- promlinter
|
||||
- staticcheck
|
||||
- testableexamples
|
||||
- tparallel
|
||||
- unconvert
|
||||
- unused
|
||||
- sloglint
|
||||
- usetesting
|
||||
- gocritic
|
||||
- wastedassign
|
||||
- whitespace
|
||||
exclusions:
|
||||
@@ -40,10 +41,6 @@ linters:
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
formatters:
|
||||
enable:
|
||||
- gci
|
||||
@@ -59,7 +56,3 @@ formatters:
|
||||
custom-order: true
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
|
||||
@@ -1 +1 @@
|
||||
24.11.0
|
||||
24.15.0
|
||||
|
||||
66
AGENTS.md
66
AGENTS.md
@@ -1,66 +0,0 @@
|
||||
# Operational Guidelines for Gotenberg
|
||||
|
||||
As an AI agent working on the Gotenberg repository, you are expected to act with the diligence and architectural foresight of a Senior Go Engineer. Gotenberg is a widely used production dependency; stability and backward compatibility are paramount.
|
||||
|
||||
## 1. Core Philosophy & Stability
|
||||
|
||||
- **Backward Compatibility is Law:** This project creates a public API. Never modify existing flags, configuration environment variables, or API form fields unless explicitly instructed to perform a breaking change. If a change is breaking, it must be flagged immediately in the plan.
|
||||
- **Defensive Programming:** Assume input data is malformed. Handle errors explicitly. Do not panic.
|
||||
- **Atomic Commits:** Isolate refactoring from feature additions. A Pull Request should do one thing well.
|
||||
|
||||
## 2. Development Workflow & Tooling
|
||||
|
||||
You must rely strictly on the project's Makefile for build and verification tasks. Do not run `go` commands directly unless debugging a specific package requires it.
|
||||
|
||||
- **Formatting:** Run `make fmt` to format Go code before committing.
|
||||
- **Linting:**
|
||||
- Run `make lint` to ensure Go code strictly adheres to the `.golangci.yml` configuration.
|
||||
- Run `make lint-prettier` to verify formatting for non-Go files (Markdown, YAML, etc.).
|
||||
- Zero linting errors are permitted.
|
||||
- **Building:** Run `make build` to verify compilation and Docker image construction.
|
||||
|
||||
## 3. Architecture & Code Structure
|
||||
|
||||
- **Idiomatic Go:** Follow "Effective Go" principles.
|
||||
- **Directory Separation:**
|
||||
- `cmd/`: Application entry points only. Contains wiring and startup logic. **No business logic is permitted here.**
|
||||
- `pkg/`: Core library code and modules. All business logic resides here.
|
||||
- **Module System:** Gotenberg is modular (e.g., Chromium, LibreOffice). When adding features, determine if they belong to an existing module or require a new strict isolation.
|
||||
|
||||
## 4. Testing Standards
|
||||
|
||||
Gotenberg utilizes a split testing strategy. **Integration tests are the primary and preferred method for verifying features.**
|
||||
|
||||
- **Integration Tests (`make test-integration`):**
|
||||
- **First Priority:** Always start here when adding features or routes.
|
||||
- Gotenberg uses **Gherkin (Godog)** for end-to-end verification.
|
||||
- You **must** create or update the corresponding `.feature` file in `test/integration`.
|
||||
- These tests run within the Docker context; ensure environment consistency.
|
||||
- **Unit Tests (`make test-unit`):**
|
||||
- Use table-driven tests for pure logic within `pkg/`.
|
||||
- Mock external dependencies (filesystem, network) where appropriate.
|
||||
|
||||
## 5. Documentation Requirements
|
||||
|
||||
- **No README Updates:** Do not modify the root `README.md` unless explicitly asked.
|
||||
- **GoDoc is Mandatory:**
|
||||
- **New Packages:** If creating a new package, you must include a `doc.go` file containing the package-level documentation.
|
||||
- **Exported Symbols:** Every exported function, type, constant, and variable must have a proper GoDoc comment starting with its name.
|
||||
- **Quality:** Comments must be complete sentences explaining _what_ the symbol does and _how_ to use it.
|
||||
- **Example:**
|
||||
```go
|
||||
// Convert transforms the input document to PDF using the Chromium engine.
|
||||
// It returns an error if the connection to the browser instance fails.
|
||||
func Convert(...) error
|
||||
```
|
||||
|
||||
## 6. Definition of Done
|
||||
|
||||
A task is considered complete only when:
|
||||
|
||||
1. The code compiles via `make build`.
|
||||
2. The code is formatted via `make fmt`.
|
||||
3. All linters pass via `make lint` and `make lint-prettier`.
|
||||
4. Integration scenarios pass via `make test-integration`.
|
||||
5. Unit tests pass via `make test-unit`.
|
||||
6. All exported symbols and new packages have compliant GoDoc.
|
||||
178
CONTRIBUTING.md
Normal file
178
CONTRIBUTING.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# Contributing to Gotenberg
|
||||
|
||||
Gotenberg is a Docker-based API for converting documents to PDF. Two rules override everything else:
|
||||
|
||||
- **Backward compatibility.** Never rename or remove CLI flags, environment variables, API form fields, or HTTP endpoints without discussion.
|
||||
- **Defensive programming.** Assume input is malformed, handle errors explicitly, never panic.
|
||||
|
||||
## Toolchain
|
||||
|
||||
- Module: `github.com/gotenberg/gotenberg/v8`
|
||||
- Go: see version in `go.mod`
|
||||
- Docker
|
||||
- Node.js (see `.node-version`), for Prettier linting
|
||||
- [golangci-lint](https://golangci-lint.run/) v2+
|
||||
|
||||
## Before you start
|
||||
|
||||
For non-trivial changes, open an issue or a draft PR first. Describe what needs to change, the proposed solution (files to modify, interface changes, form fields), and which integration test tags are affected.
|
||||
|
||||
One thing per PR. Keep features, bug fixes, and refactoring in separate PRs.
|
||||
|
||||
When adding a feature or route, write the Gherkin scenario before the Go code, and plan to update the Bruno collection (`.bruno/`) if a route changes.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
cmd/gotenberg/ -> Entry point only (wiring/startup). No business logic.
|
||||
pkg/gotenberg/ -> Core module system, interfaces, utilities, mocks.
|
||||
pkg/modules/ -> Feature modules (api, chromium, libreoffice, pdfengines, etc.).
|
||||
pkg/standard/ -> Wires all standard modules together via imports.
|
||||
test/integration/ -> Gherkin feature files + Go test infrastructure.
|
||||
build/ -> Dockerfile, fonts, Chromium config.
|
||||
.bruno/ -> Bruno API collection (mirrors every route).
|
||||
```
|
||||
|
||||
Key interfaces live in `pkg/gotenberg/`: `Module`, `Provisioner`, `Validator`, `Debuggable`. Every module implements `Descriptor()` and self-registers via `init()`.
|
||||
|
||||
## Setup and Makefile
|
||||
|
||||
All build and verification tasks go through the Makefile. Do not run `go` commands directly unless debugging a specific package.
|
||||
|
||||
| Command | Purpose | When to use |
|
||||
| ----------------------- | ------------------------------------------------ | ------------------------------------------------------------------------ |
|
||||
| `make build` | Build the Gotenberg Docker image | Before integration tests or manual testing |
|
||||
| `make run` | Run a Gotenberg container via `docker compose` | Manual testing. Flags configured via Makefile variables and compose.yaml |
|
||||
| `make telemetry` | Start an OpenTelemetry collector and OpenObserve | When testing telemetry locally |
|
||||
| `make down` | Stop all compose containers | After manual testing |
|
||||
| `make godoc` | Serve GoDoc at `localhost:6060` | To verify documentation |
|
||||
| `make fmt` | Format Go code | Before committing |
|
||||
| `make lint` | Lint Go code (zero errors permitted) | Before committing |
|
||||
| `make prettify` | Format non-Go files (Markdown, YAML, JSON) | Before committing |
|
||||
| `make lint-prettier` | Lint non-Go files | Before committing |
|
||||
| `make test-unit` | Run unit tests | Before committing |
|
||||
| `make test-integration` | Run all integration tests (40 min timeout) | Before committing |
|
||||
|
||||
Run only the integration test tag(s) relevant to your change rather than the full suite:
|
||||
|
||||
```bash
|
||||
make test-integration TAGS=health
|
||||
make test-integration TAGS=chromium-convert-html
|
||||
make test-integration TAGS="merge,split"
|
||||
```
|
||||
|
||||
## Code conventions
|
||||
|
||||
### Module system
|
||||
|
||||
Gotenberg uses a self-registering module architecture inspired by CaddyServer. Each module lives in `pkg/modules/<name>/`, implements at minimum `gotenberg.Module` (`Descriptor()`), and self-registers via `init()`. Wiring happens through `pkg/standard/`.
|
||||
|
||||
Determine if a feature belongs in an existing module before creating a new one. Only create a new module for a genuinely separate concern.
|
||||
|
||||
The `cmd/gotenberg/` package is strictly for wiring and startup. No business logic.
|
||||
|
||||
### Backward compatibility
|
||||
|
||||
CLI flags, environment variables, API form fields, HTTP endpoints, and default values that alter existing behavior must not change without discussion. Deprecate old names with `fs.MarkDeprecated()` and register both the old and new names side by side.
|
||||
|
||||
If a change violates backward compatibility, flag it as a breaking change in the PR description.
|
||||
|
||||
### Error handling
|
||||
|
||||
- Wrap every error with context: `fmt.Errorf("description: %w", err)`.
|
||||
- Never swallow errors silently.
|
||||
- Match errors with `errors.Is`, never `strings.Contains`.
|
||||
- No panics in production code paths.
|
||||
- Validate input defensively.
|
||||
|
||||
### 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.
|
||||
|
||||
### Telemetry
|
||||
|
||||
External tool calls (Chromium, LibreOffice, PDF engines, webhooks, downloads) must create OTEL spans with `trace.SpanKindClient` and `semconv.ServerAddress("toolname")`. Use `gotenberg.Tracer()` and `gotenberg.Meter()` for traces and metrics.
|
||||
|
||||
### Import ordering
|
||||
|
||||
Enforced by `gci`: standard library, then third-party, then `github.com/gotenberg/gotenberg/v8`. Three groups separated by blank lines.
|
||||
|
||||
## Documentation conventions
|
||||
|
||||
### Tone
|
||||
|
||||
- Short, declarative sentences. Say what it does, then stop.
|
||||
- Lead with the action. "Validates font embedding", not "This function validates font embedding".
|
||||
- Active voice. "Gotenberg checks the profile", not "The profile is checked by Gotenberg".
|
||||
- No em dashes. Use a period, colon, or comma.
|
||||
- No "we" hedging. "Don't...", not "We do not recommend...".
|
||||
|
||||
### Godoc
|
||||
|
||||
Every exported type and function has a Godoc comment starting with its identifier name:
|
||||
|
||||
```go
|
||||
// Violation records a single rule violation with context.
|
||||
type Violation struct { ... }
|
||||
|
||||
// ValidatePDFA audits the document against a PDF/A profile.
|
||||
func ValidatePDFA(ctx context.Context, ...) ([]error, error)
|
||||
```
|
||||
|
||||
Each package should have a `doc.go` with a `// Package foo ...` comment.
|
||||
|
||||
Reference identifiers with `[Name]` brackets for pkg.go.dev linking:
|
||||
|
||||
```go
|
||||
// ValidatePDFA returns violations as []error where each element
|
||||
// is a [Violation] value. See [Rule] for the structured fields.
|
||||
```
|
||||
|
||||
### Code comments
|
||||
|
||||
- Explain _why_, not _what_.
|
||||
- No numbered step comments (`// 1. Do X`, `// 2. Do Y`).
|
||||
- No section dividers with numbers (`// --- 8. Foo ---`). Plain dividers are fine for major boundaries.
|
||||
- No noise comments that restate the code (`// Check if err is nil`, `// Return results`).
|
||||
- Reference spec clauses where relevant (`// Per ISO 32000-2, Table 116...`).
|
||||
- Mark debt with `// TODO: [context]`.
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit tests
|
||||
|
||||
Table-driven tests in `*_test.go` files. Use the comprehensive mock implementations in `pkg/gotenberg/mocks.go` rather than rolling new ones.
|
||||
|
||||
### Integration tests
|
||||
|
||||
Gherkin (BDD) via Godog with `testcontainers-go` for Docker orchestration. Feature files live in `test/integration/features/`; step definitions live in `test/integration/scenario/`. Read `scenario.go` and `containers.go` before writing new tests.
|
||||
|
||||
`make build` is required before running integration tests. The full suite has a 40-minute timeout, so run only the tag(s) relevant to your change.
|
||||
|
||||
## Pull requests
|
||||
|
||||
### Commits
|
||||
|
||||
[Conventional Commits](https://www.conventionalcommits.org/): `<type>(<scope>): <description>`.
|
||||
|
||||
Common types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `ci`, `build`. The scope matches the module or area of the change (e.g., `chromium`, `pdfengines`, `api`).
|
||||
|
||||
Stage specific files. Never `git add -A` or `git add .`.
|
||||
|
||||
### Checklist
|
||||
|
||||
Before opening the PR, confirm:
|
||||
|
||||
- [ ] No backward-compatibility regression. See [Backward compatibility](#backward-compatibility).
|
||||
- [ ] Code conventions met (error wrapping, logging, telemetry, import ordering, no panics, no business logic in `cmd/`). See [Code conventions](#code-conventions).
|
||||
- [ ] Documentation conventions met (Godoc on every exported identifier, `doc.go` for new packages, tone). See [Documentation conventions](#documentation-conventions).
|
||||
- [ ] `make fmt && make lint && make prettify && make lint-prettier` pass with zero warnings.
|
||||
- [ ] `make test-unit` passes.
|
||||
- [ ] Relevant `make test-integration TAGS=...` passes.
|
||||
- [ ] Bruno collection updated if routes were added or modified.
|
||||
|
||||
## Further reading
|
||||
|
||||
- [`test/integration/README.md`](test/integration/README.md) — Gherkin step reference, available tags, writing new tests.
|
||||
- [`.bruno/README.md`](.bruno/README.md) — `.bru` file format, conventions, route update checklist.
|
||||
- [`pkg/modules/pdfengines/README.md`](pkg/modules/pdfengines/README.md) — adding new engine features (Makefile variable and flag).
|
||||
55
Makefile
55
Makefile
@@ -5,8 +5,9 @@ help: ## Show the help
|
||||
@grep -hE '^[A-Za-z0-9_ \-]*?:.*##.*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.PHONY: build
|
||||
build: ## Build the Gotenberg's Docker image
|
||||
build: ## Build the Gotenberg's Docker image (use TARGET=gotenberg-chromium or TARGET=gotenberg-libreoffice for variants)
|
||||
docker build \
|
||||
--target $(TARGET) \
|
||||
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
|
||||
-f $(DOCKERFILE) $(DOCKER_BUILD_CONTEXT)
|
||||
|
||||
@@ -26,16 +27,17 @@ API_ENABLE_BASIC_AUTH=false
|
||||
GOTENBERG_API_BASIC_AUTH_USERNAME=
|
||||
GOTENBERG_API_BASIC_AUTH_PASSWORD=
|
||||
API_DOWNLOAD_FROM_ALLOW_LIST=
|
||||
API_DOWNLOAD_FROM_DENY_LIST=
|
||||
API_DOWNLOAD_FROM_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)
|
||||
API_DOWNLOAD_FROM_MAX_RETRY=4
|
||||
API_DISABLE_DOWNLOAD_FROM=false
|
||||
API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=false
|
||||
API_DISABLE_ROOT_ROUTE_TELEMETRY=false
|
||||
API_DISABLE_VERSION_ROUTE_TELEMETRY=false
|
||||
API_DISABLE_DEBUG_ROUTE_TELEMETRY=false
|
||||
API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=true
|
||||
API_DISABLE_ROOT_ROUTE_TELEMETRY=true
|
||||
API_DISABLE_DEBUG_ROUTE_TELEMETRY=true
|
||||
API_DISABLE_VERSION_ROUTE_TELEMETRY=true
|
||||
API_ENABLE_DEBUG_ROUTE=false
|
||||
CHROMIUM_RESTART_AFTER=100
|
||||
CHROMIUM_MAX_QUEUE_SIZE=0
|
||||
CHROMIUM_IDLE_SHUTDOWN_TIMEOUT=0
|
||||
CHROMIUM_MAX_CONCURRENCY=6
|
||||
CHROMIUM_AUTO_START=false
|
||||
CHROMIUM_START_TIMEOUT=20s
|
||||
@@ -53,22 +55,36 @@ CHROMIUM_DISABLE_JAVASCRIPT=false
|
||||
CHROMIUM_DISABLE_ROUTES=false
|
||||
LIBREOFFICE_RESTART_AFTER=10
|
||||
LIBREOFFICE_MAX_QUEUE_SIZE=0
|
||||
LIBREOFFICE_IDLE_SHUTDOWN_TIMEOUT=0
|
||||
LIBREOFFICE_AUTO_START=false
|
||||
LIBREOFFICE_START_TIMEOUT=20s
|
||||
LIBREOFFICE_ALLOW_LIST=
|
||||
LIBREOFFICE_DENY_LIST=
|
||||
LIBREOFFICE_DISABLE_ROUTES=false
|
||||
LOG_LEVEL=info
|
||||
LOG_FIELDS_PREFIX=
|
||||
LOG_STD_FORMAT=auto
|
||||
LOG_STD_ENABLE_GCP_FIELDS=false
|
||||
PDFENGINES_DISABLE_ROUTES=false
|
||||
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
|
||||
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
|
||||
PDFENGINES_FLATTEN_ENGINES=qpdf
|
||||
PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
|
||||
PDFENGINES_READ_METADATA_ENGINES=exiftool
|
||||
PDFENGINES_WRITE_METADATA_ENGINES=exiftool
|
||||
PDFENGINES_READ_BOOKMARKS_ENGINES=pdfcpu
|
||||
PDFENGINES_WRITE_BOOKMARKS_ENGINES=pdfcpu
|
||||
PDFENGINES_WATERMARK_ENGINES=pdfcpu,pdftk
|
||||
PDFENGINES_STAMP_ENGINES=pdfcpu,pdftk
|
||||
PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
|
||||
PDFENGINES_DISABLE_ROUTES=false
|
||||
PDFENGINES_ADD_ATTACHMENTS_ENGINES=pdfcpu
|
||||
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
|
||||
PDFENGINES_EMBED_ENGINES=qpdf,pdfcpu
|
||||
PDFENGINES_EMBED_METADATA_ENGINES=qpdf
|
||||
PROMETHEUS_NAMESPACE=gotenberg
|
||||
PROMETHEUS_COLLECT_INTERVAL=1s
|
||||
PROMETHEUS_DISABLE_ROUTE_TELEMETRY=true
|
||||
PROMETHEUS_DISABLE_COLLECT=false
|
||||
PROMETHEUS_METRICS_PATH=/prometheus/metrics
|
||||
OTEL_SERVICE_NAME=gotenberg
|
||||
OTEL_TRACES_EXPORTER=none
|
||||
OTEL_METRICS_EXPORTER=none
|
||||
@@ -78,9 +94,7 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
||||
OTEL_EXPORTER_OTLP_INSECURE=true
|
||||
WEBHOOK_ENABLE_SYNC_MODE=false
|
||||
WEBHOOK_ALLOW_LIST=
|
||||
WEBHOOK_DENY_LIST=
|
||||
WEBHOOK_ERROR_ALLOW_LIST=
|
||||
WEBHOOK_ERROR_DENY_LIST=
|
||||
WEBHOOK_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)
|
||||
WEBHOOK_MAX_RETRY=4
|
||||
WEBHOOK_RETRY_MIN_WAIT=1s
|
||||
WEBHOOK_RETRY_MAX_WAIT=30s
|
||||
@@ -114,6 +128,9 @@ NO_CONCURRENCY=false
|
||||
# chromium-convert-html
|
||||
# chromium-convert-markdown
|
||||
# chromium-convert-url
|
||||
# chromium-screenshot-html
|
||||
# chromium-screenshot-markdown
|
||||
# chromium-screenshot-url
|
||||
# debug
|
||||
# health
|
||||
# libreoffice
|
||||
@@ -121,8 +138,8 @@ NO_CONCURRENCY=false
|
||||
# output-filename
|
||||
# pdfengines
|
||||
# pdfengines-convert
|
||||
# pdfengines-attachments
|
||||
# attachments
|
||||
# pdfengines-embed
|
||||
# embed
|
||||
# pdfengines-encrypt
|
||||
# encrypt
|
||||
# pdfengines-flatten
|
||||
@@ -133,15 +150,23 @@ NO_CONCURRENCY=false
|
||||
# metadata
|
||||
# pdfengines-split
|
||||
# split
|
||||
# pdfengines-watermark
|
||||
# watermark
|
||||
# pdfengines-stamp
|
||||
# stamp
|
||||
# pdfengines-rotate
|
||||
# rotate
|
||||
# pdfengines-bookmarks
|
||||
# bookmarks
|
||||
# prometheus-metrics
|
||||
# root
|
||||
# version
|
||||
# webhook
|
||||
# download-from
|
||||
# telemetry
|
||||
TAGS=
|
||||
|
||||
.PHONY: test-integration
|
||||
test-integration: ## Run integration tests
|
||||
test-integration: ## Run integration tests (automatically retries failed scenarios up to 3 times)
|
||||
go test -timeout 40m -tags=integration -v github.com/gotenberg/gotenberg/v8/test/integration -args \
|
||||
--gotenberg-docker-repository=$(DOCKER_REPOSITORY) \
|
||||
--gotenberg-version=$(GOTENBERG_VERSION) \
|
||||
|
||||
51
README.md
51
README.md
@@ -1,7 +1,7 @@
|
||||
<p align="center">
|
||||
<img src="https://user-images.githubusercontent.com/8983173/130322857-185831e2-f041-46eb-a17f-0a69d066c4e5.png" alt="Gotenberg Logo" width="150" height="150" />
|
||||
<img src="https://raw.githubusercontent.com/gotenberg/art/master/logo.png" alt="Gotenberg Logo" width="150" height="150" />
|
||||
<h3 align="center">Gotenberg</h3>
|
||||
<p align="center">A containerized API for seamless PDF conversion</p>
|
||||
<p align="center">A Docker-based API for converting documents to PDF</p>
|
||||
<p align="center">
|
||||
<a href="https://hub.docker.com/r/gotenberg/gotenberg"><img alt="Total downloads (gotenberg/gotenberg)" src="https://img.shields.io/docker/pulls/gotenberg/gotenberg"></a>
|
||||
<a href="https://github.com/gotenberg/gotenberg/actions/workflows/continuous-integration.yml"><img alt="Continuous Integration" src="https://github.com/gotenberg/gotenberg/actions/workflows/continuous-integration.yml/badge.svg"></a>
|
||||
@@ -10,38 +10,61 @@
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/2996"><img src="https://trendshift.io/api/badge/repositories/2996" alt="gotenberg%2Fgotenberg | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
<p align="center"><a href="https://gotenberg.dev/docs/getting-started/introduction">Read the Documentation</a> · <a href="https://gotenberg.dev/docs/getting-started/installation#live-demo-">Try the Live Demo</a> 🔥</p>
|
||||
<p align="center">
|
||||
<a href="https://gotenberg.dev/docs/getting-started/introduction"><strong>Documentation</strong></a> ·
|
||||
<a href="https://gotenberg.dev/docs/getting-started/installation#live-demo"><strong>Live Demo</strong></a> ·
|
||||
<a href="https://github.com/gotenberg/gotenberg/discussions"><strong>Community</strong></a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
**Gotenberg** is a containerized API that abstracts the complexity of PDF conversion.
|
||||
**Gotenberg** is a Docker-based API for converting documents to PDF. Trusted in production by thousands of companies. Also adopted by notable open-source projects.
|
||||
|
||||
It provides a `multipart/form-data` interface for interacting with powerful engines like Chromium and LibreOffice.
|
||||
Instead of managing heavy dependencies, browser versions, or fonts in your own backend, simply send your files to
|
||||
Gotenberg and get a PDF in return.
|
||||
Send your files via `multipart/form-data`, get a PDF back. No need to manage Chromium, LibreOffice, or fonts yourself.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Open a terminal and run the following command:
|
||||
|
||||
```bash
|
||||
docker run --rm -p 3000:3000 gotenberg/gotenberg:8
|
||||
```
|
||||
|
||||
With the API running at `http://localhost:3000`, you are now ready to head
|
||||
to the **[Full Documentation](https://gotenberg.dev/docs/getting-started/introduction)** to discover how to convert URLs,
|
||||
local files, inject custom CSS, merge PDFs, and more.
|
||||
Convert a URL to PDF:
|
||||
|
||||
```bash
|
||||
curl \
|
||||
--request POST http://localhost:3000/forms/chromium/convert/url \
|
||||
--form url=https://sparksuite.github.io/simple-html-invoice-template/ \
|
||||
-o invoice.pdf
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **HTML, URL, Markdown to PDF** via Headless Chromium
|
||||
- **Office documents to PDF** via LibreOffice (100+ formats)
|
||||
- **Merge, split, rotate, flatten** PDFs
|
||||
- **Watermark, stamp, encrypt** PDFs
|
||||
- **PDF/A and PDF/UA** compliance
|
||||
- **Screenshots** of URLs and HTML
|
||||
- **Read/write metadata and bookmarks**
|
||||
|
||||
See the [full documentation](https://gotenberg.dev/docs/getting-started/introduction).
|
||||
|
||||
## Contributing
|
||||
|
||||
Questions and feedback: [GitHub Discussions](https://github.com/gotenberg/gotenberg/discussions).
|
||||
Bug reports: [GitHub Issues](https://github.com/gotenberg/gotenberg/issues).
|
||||
|
||||
## Sponsors
|
||||
|
||||
Open-source development takes a significant amount of time, energy, and dedication. If Gotenberg helps streamline your
|
||||
workflow or powers your business, please consider supporting its continuous improvement by [**becoming a sponsor**](https://github.com/sponsors/gulien)! ❤️
|
||||
If Gotenberg powers your workflow or your business, consider [**becoming a sponsor**](https://github.com/sponsors/gulien).
|
||||
|
||||
**Historic & GitHub Sponsors**
|
||||
|
||||
- [TheCodingMachine](https://thecodingmachine.com/)
|
||||
- [pdfme](https://pdfme.com/)
|
||||
- [PDFBolt](https://pdfbolt.com)
|
||||
- [FileToPDF.dev](https://filetopdf.dev)
|
||||
|
||||
**Powered By**
|
||||
|
||||
|
||||
34
SECURITY.md
34
SECURITY.md
@@ -2,40 +2,28 @@
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Please ensure to keep your environment up to date and use only the latest version of Gotenberg.
|
||||
Security updates and patches will be applied only to the most recent version.
|
||||
Only the latest version receives security updates and patches. Keep your environment up to date.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Your help in identifying vulnerabilities in our project is much appreciated.
|
||||
We take all reports regarding security seriously.
|
||||
Do not publish vulnerabilities publicly. Report them through [GitHub's private vulnerability reporting](https://github.com/gotenberg/gotenberg/security/advisories/new).
|
||||
|
||||
If you discover a security vulnerability, please refrain from publishing it publicly.
|
||||
Instead, kindly send us the details via email to _neuhart [dot] julien [at] gmail [dot] com_.
|
||||
|
||||
In the subject of your email, please indicate that it's a security vulnerability report for Gotenberg.
|
||||
In your message, please include:
|
||||
Include:
|
||||
|
||||
- A detailed description of the vulnerability.
|
||||
- The steps to reproduce the issue.
|
||||
- Any potential impact of the vulnerability on the users or system.
|
||||
- Steps to reproduce the issue.
|
||||
- Potential impact on users or the system.
|
||||
|
||||
Please remember that this process is done in a _'best-effort'_ manner.
|
||||
This means we strive to respond and act as quickly as possible, but the speed may vary depending on the severity of
|
||||
the issue and our resources.
|
||||
|
||||
Thank you in advance for helping to keep our project safe!
|
||||
This process is handled on a best-effort basis. Response speed may vary depending on severity and available resources.
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
Once we have received your vulnerability report, we will work to validate and reproduce the issue.
|
||||
If we can confirm the vulnerability, we will proceed to:
|
||||
Once a report is received and confirmed:
|
||||
|
||||
- Work on a fix and a release timeline.
|
||||
- Notify you when the fix has been implemented and released.
|
||||
- Credit you for discovering the vulnerability (unless you request anonymity).
|
||||
- Please note that we will do our best to keep you informed about the progress towards resolving the issue.
|
||||
- A fix and release timeline will be prepared.
|
||||
- The reporter will be notified when the fix is released.
|
||||
- The reporter will be credited for the discovery (unless anonymity is requested).
|
||||
|
||||
## Comments on this Policy
|
||||
|
||||
If you have suggestions on how this process could be improved, please submit a pull request.
|
||||
Submit a pull request with suggestions for improving this process.
|
||||
|
||||
468
build/Dockerfile
468
build/Dockerfile
@@ -1,7 +1,7 @@
|
||||
# ARG instructions do not create additional layers. Instead, next layers will
|
||||
# concatenate them. Also, we have to repeat ARG instructions in each build
|
||||
# stage that uses them.
|
||||
ARG GOLANG_VERSION=1.26.0
|
||||
ARG GOLANG_VERSION=1.26.2
|
||||
|
||||
# ----------------------------------------------
|
||||
# pdfcpu binary build stage
|
||||
@@ -11,22 +11,22 @@ ARG GOLANG_VERSION=1.26.0
|
||||
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
|
||||
|
||||
# See https://github.com/pdfcpu/pdfcpu/releases.
|
||||
ARG PDFCPU_VERSION=v0.11.1
|
||||
ARG PDFCPU_VERSION=v0.12.0
|
||||
ENV CGO_ENABLED=0
|
||||
|
||||
# Define the working directory outside of $GOPATH (we're using go modules).
|
||||
WORKDIR /home
|
||||
|
||||
RUN curl -Ls "https://github.com/pdfcpu/pdfcpu/archive/refs/tags/$PDFCPU_VERSION.tar.gz" -o pdfcpu.tar.gz &&\
|
||||
tar --strip-components=1 -xvzf pdfcpu.tar.gz
|
||||
RUN curl -Ls "https://github.com/pdfcpu/pdfcpu/archive/refs/tags/$PDFCPU_VERSION.tar.gz" -o pdfcpu.tar.gz \
|
||||
&& tar --strip-components=1 -xvzf pdfcpu.tar.gz
|
||||
|
||||
# Install module dependencies.
|
||||
RUN go mod download &&\
|
||||
go mod verify
|
||||
RUN go mod download \
|
||||
&& go mod verify
|
||||
|
||||
RUN go build -o pdfcpu -ldflags "-s -w -X 'main.version=$PDFCPU_VERSION' -X 'github.com/pdfcpu/pdfcpu/pkg/pdfcpu.VersionStr=$PDFCPU_VERSION' -X main.builtBy=gotenberg" ./cmd/pdfcpu &&\
|
||||
RUN go build -o pdfcpu -ldflags "-s -w -X 'main.version=$PDFCPU_VERSION' -X 'github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model.VersionStr=$PDFCPU_VERSION' -X main.builtBy=gotenberg" ./cmd/pdfcpu \
|
||||
# Verify installation.
|
||||
./pdfcpu version
|
||||
&& ./pdfcpu version
|
||||
|
||||
# ----------------------------------------------
|
||||
# Gotenberg binary build stage
|
||||
@@ -42,14 +42,16 @@ WORKDIR /home
|
||||
# Install module dependencies.
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
RUN go mod download &&\
|
||||
go mod verify
|
||||
RUN go mod download \
|
||||
&& go mod verify
|
||||
|
||||
# Copy the source code.
|
||||
COPY cmd ./cmd
|
||||
COPY pkg ./pkg
|
||||
|
||||
RUN go build -o gotenberg -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go
|
||||
RUN go build -o gotenberg -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go \
|
||||
&& go build -o gotenberg-chromium -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg-chromium/main.go \
|
||||
&& go build -o gotenberg-libreoffice -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg-libreoffice/main.go
|
||||
|
||||
# ----------------------------------------------
|
||||
# Custom JRE stage
|
||||
@@ -57,14 +59,13 @@ RUN go build -o gotenberg -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/
|
||||
# ----------------------------------------------
|
||||
FROM debian:13-slim AS custom-jre-stage
|
||||
|
||||
RUN \
|
||||
apt-get update -qq &&\
|
||||
apt-get upgrade -yqq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends default-jdk-headless binutils
|
||||
RUN apt-get update -qq \
|
||||
&& apt-get upgrade -yqq \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends default-jdk-headless binutils
|
||||
|
||||
# Note: jdeps helps finding which modules a JAR requires.
|
||||
# Currently only for PDFtk, as we don't rely on LibreOffice UNO Java SDK.
|
||||
ENV JAVA_MODULES=java.base,java.desktop,java.xml
|
||||
ENV JAVA_MODULES=java.base,java.desktop,java.naming,java.sql
|
||||
|
||||
RUN jlink \
|
||||
--add-modules $JAVA_MODULES \
|
||||
@@ -82,16 +83,16 @@ FROM debian:13-slim AS downloader-stage
|
||||
# See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
|
||||
ARG PDFTK_VERSION=v3.3.3
|
||||
|
||||
RUN apt-get update -qq && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl ca-certificates
|
||||
RUN apt-get update -qq \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl ca-certificates
|
||||
|
||||
WORKDIR /downloads
|
||||
|
||||
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.2.0/unoconv -o unoconverter &&\
|
||||
chmod +x unoconverter
|
||||
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.2.0/unoconv -o unoconverter \
|
||||
&& chmod +x unoconverter
|
||||
|
||||
RUN curl -o pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" &&\
|
||||
chmod a+x 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" \
|
||||
&& chmod a+x pdftk-all.jar
|
||||
|
||||
# ----------------------------------------------
|
||||
# Base image stage
|
||||
@@ -100,48 +101,33 @@ FROM debian:13-slim AS base-image-stage
|
||||
|
||||
ARG TIMEZONE=UTC
|
||||
ENV TZ=$TIMEZONE
|
||||
# Set default characterset encoding to UTF-8.
|
||||
# See:
|
||||
# https://github.com/gotenberg/gotenberg/issues/104
|
||||
# https://github.com/gotenberg/gotenberg/issues/730
|
||||
ENV LANG=C.UTF-8
|
||||
ENV LC_ALL=C.UTF-8
|
||||
|
||||
COPY --link --from=custom-jre-stage /custom-jre /opt/java
|
||||
|
||||
ENV PATH="/opt/java/bin:${PATH}"
|
||||
|
||||
# ----------------------------------------------
|
||||
# Final stage
|
||||
# Common stage
|
||||
# ----------------------------------------------
|
||||
FROM base-image-stage
|
||||
FROM base-image-stage AS common-stage
|
||||
|
||||
ARG GOTENBERG_VERSION=snapshot
|
||||
ARG GOTENBERG_USER_GID=1001
|
||||
ARG GOTENBERG_USER_UID=1001
|
||||
|
||||
LABEL org.opencontainers.image.title="Gotenberg" \
|
||||
org.opencontainers.image.description="A containerized API for seamless PDF conversion." \
|
||||
org.opencontainers.image.version="$GOTENBERG_VERSION" \
|
||||
org.opencontainers.image.authors="Julien Neuhart <neuhart.julien@gmail.com>" \
|
||||
org.opencontainers.image.documentation="https://gotenberg.dev" \
|
||||
org.opencontainers.image.source="https://github.com/gotenberg/gotenberg"
|
||||
# Create a non-root user.
|
||||
# All processes in the Docker container will run with this dedicated user.
|
||||
RUN groupadd --gid "$GOTENBERG_USER_GID" gotenberg \
|
||||
&& useradd --uid "$GOTENBERG_USER_UID" --gid gotenberg --shell /bin/bash --home /home/gotenberg --no-create-home gotenberg \
|
||||
&& mkdir /home/gotenberg \
|
||||
&& chown gotenberg: /home/gotenberg
|
||||
|
||||
RUN \
|
||||
# Create a non-root user.
|
||||
# All processes in the Docker container will run with this dedicated user.
|
||||
groupadd --gid "$GOTENBERG_USER_GID" gotenberg &&\
|
||||
useradd --uid "$GOTENBERG_USER_UID" --gid gotenberg --shell /bin/bash --home /home/gotenberg --no-create-home gotenberg &&\
|
||||
mkdir /home/gotenberg &&\
|
||||
chown gotenberg: /home/gotenberg
|
||||
|
||||
RUN \
|
||||
echo "deb http://deb.debian.org/debian trixie-backports main" >> /etc/apt/sources.list &&\
|
||||
apt-get update -qq &&\
|
||||
apt-get upgrade -yqq &&\
|
||||
# Note: tini is a helper for reaping zombie processes.
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
|
||||
tini python3 python3-distutils-extra \
|
||||
# Note: tini is a helper for reaping zombie processes.
|
||||
RUN apt-get update -qq \
|
||||
&& apt-get upgrade -yqq \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
|
||||
tini \
|
||||
# Many users rely on curl for Docker health checks.
|
||||
curl \
|
||||
# Install fonts.
|
||||
# Essential metric-compatible fonts for LibreOffice layout fidelity (replaces MS Fonts).
|
||||
fonts-crosextra-carlito \
|
||||
@@ -154,46 +140,29 @@ RUN \
|
||||
fonts-noto-cjk \
|
||||
# Standard Emoji support.
|
||||
fonts-noto-color-emoji \
|
||||
# Install Hyphenation for LibreOffice.
|
||||
# Credits: https://wiki.archlinux.org/title/LibreOffice.
|
||||
hyphen-af hyphen-as hyphen-be hyphen-bg hyphen-bn hyphen-ca hyphen-cs hyphen-da hyphen-de hyphen-el \
|
||||
hyphen-en-gb hyphen-en-us hyphen-eo hyphen-es hyphen-fr hyphen-gl hyphen-gu hyphen-hi hyphen-hr hyphen-hu \
|
||||
hyphen-id hyphen-is hyphen-it hyphen-kn hyphen-lt hyphen-lv hyphen-ml hyphen-mn hyphen-mr hyphen-nl \
|
||||
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 \
|
||||
# Install Chromium.
|
||||
chromium \
|
||||
# Tofu prevention.
|
||||
fonts-noto-core \
|
||||
# Install QPDF & ExifTool (PDF engines).
|
||||
qpdf exiftool &&\
|
||||
# Install LibreOffice & unoconverter.
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports \
|
||||
libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw python3-uno &&\
|
||||
# unoconverter will look for the Python binary, which has to be at version 3.
|
||||
ln -s /usr/bin/python3 /usr/bin/python &&\
|
||||
qpdf exiftool \
|
||||
# See https://github.com/nextcloud/docker/issues/380.
|
||||
mkdir -p /usr/share/man/man1 &&\
|
||||
&& mkdir -p /usr/share/man/man1 \
|
||||
# Cleanup.
|
||||
apt-get autoremove -yqq &&\
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/share/doc/* /usr/share/man/* /usr/share/info/*
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# Copy downloaded artifacts (unoconverter & PDFtk).
|
||||
COPY --link --from=downloader-stage /downloads/unoconverter /usr/bin/unoconverter
|
||||
COPY --link --from=downloader-stage /downloads/pdftk-all.jar /usr/bin/pdftk-all.jar
|
||||
|
||||
RUN \
|
||||
# Install PDFtk bash wrapper.
|
||||
# See https://github.com/gotenberg/gotenberg/pull/273.
|
||||
printf '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk && \
|
||||
chmod +x /usr/bin/pdftk
|
||||
# PDFtk bash wrapper.
|
||||
# See https://github.com/gotenberg/gotenberg/pull/273.
|
||||
RUN printf '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk \
|
||||
&& chmod +x /usr/bin/pdftk
|
||||
|
||||
# Support for arbitrary user IDs (OpenShift).
|
||||
# See:
|
||||
# https://github.com/gotenberg/gotenberg/issues/1049.
|
||||
# https://docs.redhat.com/en/documentation/openshift_container_platform/4.15/html/images/creating-images#use-uid_create-images.
|
||||
RUN \
|
||||
usermod -aG root gotenberg &&\
|
||||
chgrp -R 0 /home/gotenberg &&\
|
||||
chmod -R g=u /home/gotenberg
|
||||
RUN usermod -aG root gotenberg \
|
||||
&& chgrp -R 0 /home/gotenberg \
|
||||
&& chmod -R g=u /home/gotenberg
|
||||
|
||||
# COPY instructions last to maximize cache reuse when only source code changes.
|
||||
|
||||
# Improve fonts subpixel hinting and smoothing.
|
||||
# Credits:
|
||||
@@ -201,24 +170,104 @@ RUN \
|
||||
# https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d.
|
||||
COPY --link build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf
|
||||
|
||||
# Copy dictionnaries so that hypens work on Chromium.
|
||||
# See https://github.com/gotenberg/gotenberg/issues/1293.
|
||||
COPY --link --chown="$GOTENBERG_USER_UID:$GOTENBERG_USER_GID" build/chromium-hyphen-data /opt/gotenberg/chromium-hyphen-data
|
||||
# Copy PDFtk jar.
|
||||
COPY --link --from=downloader-stage /downloads/pdftk-all.jar /usr/bin/pdftk-all.jar
|
||||
|
||||
# Copy the Golang binaries.
|
||||
# Copy the pdfcpu binary (shared across all variants).
|
||||
COPY --link --from=pdfcpu-binary-stage /home/pdfcpu /usr/bin/
|
||||
COPY --link --from=gotenberg-binary-stage /home/gotenberg /usr/bin/
|
||||
|
||||
# Environment variables required by modules or else.
|
||||
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
|
||||
ENV CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data
|
||||
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
|
||||
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
|
||||
# Set default characterset encoding to UTF-8.
|
||||
# See:
|
||||
# https://github.com/gotenberg/gotenberg/issues/104
|
||||
# https://github.com/gotenberg/gotenberg/issues/730
|
||||
ENV LANG=C.UTF-8
|
||||
ENV LC_ALL=C.UTF-8
|
||||
|
||||
# Environment variables for PDF engines.
|
||||
ENV PDFTK_BIN_PATH=/usr/bin/pdftk
|
||||
ENV QPDF_BIN_PATH=/usr/bin/qpdf
|
||||
ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool
|
||||
ENV PDFCPU_BIN_PATH=/usr/bin/pdfcpu
|
||||
|
||||
# OpenTelemetry defaults (noop - no telemetry overhead unless explicitly enabled).
|
||||
ENV OTEL_TRACES_EXPORTER=none
|
||||
ENV OTEL_METRICS_EXPORTER=none
|
||||
ENV OTEL_LOGS_EXPORTER=none
|
||||
|
||||
# ----------------------------------------------
|
||||
# Final stage: full (Chromium + LibreOffice)
|
||||
# ----------------------------------------------
|
||||
FROM common-stage AS gotenberg
|
||||
|
||||
ARG GOTENBERG_VERSION=snapshot
|
||||
ARG GOTENBERG_USER_GID=1001
|
||||
ARG GOTENBERG_USER_UID=1001
|
||||
ARG TMP_CHROMIUM_VERSION_PPC64EL="146.0.7680.80-1~deb13u1"
|
||||
|
||||
LABEL org.opencontainers.image.title="Gotenberg" \
|
||||
org.opencontainers.image.description="A Docker-based API for converting documents to PDF." \
|
||||
org.opencontainers.image.version="$GOTENBERG_VERSION" \
|
||||
org.opencontainers.image.authors="Julien Neuhart <neuhart.julien@gmail.com>" \
|
||||
org.opencontainers.image.documentation="https://gotenberg.dev" \
|
||||
org.opencontainers.image.source="https://github.com/gotenberg/gotenberg"
|
||||
|
||||
# Install Chromium.
|
||||
# On ppc64el, the latest Chromium is broken, so we pin a known working
|
||||
# version from snapshot.debian.org via debsnap.
|
||||
RUN /bin/bash -c \
|
||||
'set -e &&\
|
||||
if [[ "$(dpkg --print-architecture)" == "ppc64el" ]]; then \
|
||||
apt-get update -qq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends devscripts &&\
|
||||
debsnap chromium-common "$TMP_CHROMIUM_VERSION_PPC64EL" -v --force --binary --architecture ppc64el &&\
|
||||
debsnap chromium "$TMP_CHROMIUM_VERSION_PPC64EL" -v --force --binary --architecture ppc64el &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install --fix-broken -y -qq --no-install-recommends "./binary-chromium-common/chromium-common_${TMP_CHROMIUM_VERSION_PPC64EL}_ppc64el.deb" "./binary-chromium/chromium_${TMP_CHROMIUM_VERSION_PPC64EL}_ppc64el.deb" &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq devscripts &&\
|
||||
rm -rf ./binary-chromium-common/* ./binary-chromium/*; \
|
||||
else \
|
||||
apt-get update -qq &&\
|
||||
apt-get upgrade -yqq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \
|
||||
fi' \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# Install LibreOffice & unoconverter.
|
||||
RUN echo "deb http://deb.debian.org/debian trixie-backports main" >> /etc/apt/sources.list \
|
||||
&& apt-get update -qq \
|
||||
&& apt-get upgrade -yqq \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
|
||||
python3 python3-distutils-extra \
|
||||
# Install Hyphenation for LibreOffice.
|
||||
# Credits: https://wiki.archlinux.org/title/LibreOffice.
|
||||
hyphen-af hyphen-as hyphen-be hyphen-bg hyphen-bn hyphen-ca hyphen-cs hyphen-da hyphen-de hyphen-el \
|
||||
hyphen-en-gb hyphen-en-us hyphen-eo hyphen-es hyphen-fr hyphen-gl hyphen-gu hyphen-hi hyphen-hr hyphen-hu \
|
||||
hyphen-id hyphen-is hyphen-it hyphen-kn hyphen-lt hyphen-lv hyphen-ml hyphen-mn hyphen-mr hyphen-nl \
|
||||
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 \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports \
|
||||
libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw python3-uno \
|
||||
# unoconverter will look for the Python binary, which has to be at version 3.
|
||||
&& ln -s /usr/bin/python3 /usr/bin/python \
|
||||
# Cleanup.
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# COPY instructions last to maximize cache reuse.
|
||||
|
||||
# Copy the Gotenberg binary (full variant with all modules).
|
||||
COPY --link --from=gotenberg-binary-stage /home/gotenberg /usr/bin/
|
||||
|
||||
# Copy unoconverter.
|
||||
COPY --link --from=downloader-stage /downloads/unoconverter /usr/bin/unoconverter
|
||||
|
||||
# Copy dictionnaries so that hyphens work on Chromium.
|
||||
# See https://github.com/gotenberg/gotenberg/issues/1293.
|
||||
COPY --link --chown="$GOTENBERG_USER_UID:$GOTENBERG_USER_GID" build/chromium-hyphen-data /opt/gotenberg/chromium-hyphen-data
|
||||
|
||||
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
|
||||
ENV CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data
|
||||
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
|
||||
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
|
||||
|
||||
USER gotenberg
|
||||
WORKDIR /home/gotenberg
|
||||
|
||||
@@ -226,4 +275,235 @@ WORKDIR /home/gotenberg
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT [ "/usr/bin/tini", "--" ]
|
||||
CMD [ "gotenberg" ]
|
||||
CMD [ "gotenberg" ]
|
||||
|
||||
# ----------------------------------------------
|
||||
# Final stage: Chromium only
|
||||
# ----------------------------------------------
|
||||
FROM common-stage AS gotenberg-chromium
|
||||
|
||||
ARG GOTENBERG_VERSION=snapshot
|
||||
ARG GOTENBERG_USER_GID=1001
|
||||
ARG GOTENBERG_USER_UID=1001
|
||||
ARG TMP_CHROMIUM_VERSION_PPC64EL="146.0.7680.80-1~deb13u1"
|
||||
|
||||
LABEL org.opencontainers.image.title="Gotenberg (Chromium)" \
|
||||
org.opencontainers.image.description="A Docker-based API for converting documents to PDF — Chromium variant." \
|
||||
org.opencontainers.image.version="$GOTENBERG_VERSION" \
|
||||
org.opencontainers.image.authors="Julien Neuhart <neuhart.julien@gmail.com>" \
|
||||
org.opencontainers.image.documentation="https://gotenberg.dev" \
|
||||
org.opencontainers.image.source="https://github.com/gotenberg/gotenberg"
|
||||
|
||||
# Install Chromium.
|
||||
# On ppc64el, the latest Chromium is broken, so we pin a known working
|
||||
# version from snapshot.debian.org via debsnap.
|
||||
RUN /bin/bash -c \
|
||||
'set -e &&\
|
||||
if [[ "$(dpkg --print-architecture)" == "ppc64el" ]]; then \
|
||||
apt-get update -qq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends devscripts &&\
|
||||
debsnap chromium-common "$TMP_CHROMIUM_VERSION_PPC64EL" -v --force --binary --architecture ppc64el &&\
|
||||
debsnap chromium "$TMP_CHROMIUM_VERSION_PPC64EL" -v --force --binary --architecture ppc64el &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install --fix-broken -y -qq --no-install-recommends "./binary-chromium-common/chromium-common_${TMP_CHROMIUM_VERSION_PPC64EL}_ppc64el.deb" "./binary-chromium/chromium_${TMP_CHROMIUM_VERSION_PPC64EL}_ppc64el.deb" &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq devscripts &&\
|
||||
rm -rf ./binary-chromium-common/* ./binary-chromium/*; \
|
||||
else \
|
||||
apt-get update -qq &&\
|
||||
apt-get upgrade -yqq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \
|
||||
fi' \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# COPY instructions last to maximize cache reuse.
|
||||
|
||||
# Copy the Gotenberg binary (Chromium variant, no LibreOffice modules).
|
||||
COPY --link --from=gotenberg-binary-stage /home/gotenberg-chromium /usr/bin/gotenberg
|
||||
|
||||
# Copy dictionnaries so that hyphens work on Chromium.
|
||||
# See https://github.com/gotenberg/gotenberg/issues/1293.
|
||||
COPY --link --chown="$GOTENBERG_USER_UID:$GOTENBERG_USER_GID" build/chromium-hyphen-data /opt/gotenberg/chromium-hyphen-data
|
||||
|
||||
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
|
||||
ENV CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data
|
||||
# No LibreOffice in this variant; override the default to use all available engines.
|
||||
ENV PDFENGINES_CONVERT_ENGINES=
|
||||
|
||||
USER gotenberg
|
||||
WORKDIR /home/gotenberg
|
||||
|
||||
# Default API port.
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT [ "/usr/bin/tini", "--" ]
|
||||
CMD [ "gotenberg" ]
|
||||
|
||||
# ----------------------------------------------
|
||||
# Final stage: LibreOffice only
|
||||
# ----------------------------------------------
|
||||
FROM common-stage AS gotenberg-libreoffice
|
||||
|
||||
ARG GOTENBERG_VERSION=snapshot
|
||||
|
||||
LABEL org.opencontainers.image.title="Gotenberg (LibreOffice)" \
|
||||
org.opencontainers.image.description="A Docker-based API for converting documents to PDF — LibreOffice variant." \
|
||||
org.opencontainers.image.version="$GOTENBERG_VERSION" \
|
||||
org.opencontainers.image.authors="Julien Neuhart <neuhart.julien@gmail.com>" \
|
||||
org.opencontainers.image.documentation="https://gotenberg.dev" \
|
||||
org.opencontainers.image.source="https://github.com/gotenberg/gotenberg"
|
||||
|
||||
# Install LibreOffice & unoconverter.
|
||||
RUN echo "deb http://deb.debian.org/debian trixie-backports main" >> /etc/apt/sources.list \
|
||||
&& apt-get update -qq \
|
||||
&& apt-get upgrade -yqq \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
|
||||
python3 python3-distutils-extra \
|
||||
# Install Hyphenation for LibreOffice.
|
||||
# Credits: https://wiki.archlinux.org/title/LibreOffice.
|
||||
hyphen-af hyphen-as hyphen-be hyphen-bg hyphen-bn hyphen-ca hyphen-cs hyphen-da hyphen-de hyphen-el \
|
||||
hyphen-en-gb hyphen-en-us hyphen-eo hyphen-es hyphen-fr hyphen-gl hyphen-gu hyphen-hi hyphen-hr hyphen-hu \
|
||||
hyphen-id hyphen-is hyphen-it hyphen-kn hyphen-lt hyphen-lv hyphen-ml hyphen-mn hyphen-mr hyphen-nl \
|
||||
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 \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports \
|
||||
libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw python3-uno \
|
||||
# unoconverter will look for the Python binary, which has to be at version 3.
|
||||
&& ln -s /usr/bin/python3 /usr/bin/python \
|
||||
# Cleanup.
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# COPY instructions last to maximize cache reuse.
|
||||
|
||||
# Copy the Gotenberg binary (LibreOffice variant, no Chromium modules).
|
||||
COPY --link --from=gotenberg-binary-stage /home/gotenberg-libreoffice /usr/bin/gotenberg
|
||||
|
||||
# Copy unoconverter.
|
||||
COPY --link --from=downloader-stage /downloads/unoconverter /usr/bin/unoconverter
|
||||
|
||||
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
|
||||
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
|
||||
|
||||
USER gotenberg
|
||||
WORKDIR /home/gotenberg
|
||||
|
||||
# Default API port.
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT [ "/usr/bin/tini", "--" ]
|
||||
CMD [ "gotenberg" ]
|
||||
|
||||
# ==============================================================
|
||||
# Cloud Run variants
|
||||
# ==============================================================
|
||||
|
||||
# ----------------------------------------------
|
||||
# Cloud Run: full (Chromium + LibreOffice)
|
||||
# ----------------------------------------------
|
||||
FROM gotenberg AS gotenberg-cloudrun
|
||||
|
||||
USER root
|
||||
|
||||
# For security reasons, the non-root user gotenberg does not own the Tini binary by default.
|
||||
# However, some providers like Cloud Run from Google Cloud cannot start a Docker container in that case.
|
||||
# See https://github.com/gotenberg/gotenberg/issues/90#issuecomment-543551353.
|
||||
RUN chown gotenberg: /usr/bin/tini
|
||||
|
||||
ENV API_PORT_FROM_ENV=PORT
|
||||
ENV CHROMIUM_AUTO_START=true
|
||||
ENV LIBREOFFICE_AUTO_START=true
|
||||
ENV WEBHOOK_ENABLE_SYNC_MODE=true
|
||||
ENV GOTENBERG_BUILD_DEBUG_DATA=false
|
||||
ENV LOG_STD_ENABLE_GCP_FIELDS=true
|
||||
|
||||
USER gotenberg
|
||||
|
||||
# ----------------------------------------------
|
||||
# Cloud Run: Chromium only
|
||||
# ----------------------------------------------
|
||||
FROM gotenberg-chromium AS gotenberg-cloudrun-chromium
|
||||
|
||||
USER root
|
||||
|
||||
RUN chown gotenberg: /usr/bin/tini
|
||||
|
||||
ENV API_PORT_FROM_ENV=PORT
|
||||
ENV CHROMIUM_AUTO_START=true
|
||||
ENV WEBHOOK_ENABLE_SYNC_MODE=true
|
||||
ENV GOTENBERG_BUILD_DEBUG_DATA=false
|
||||
ENV LOG_STD_ENABLE_GCP_FIELDS=true
|
||||
|
||||
USER gotenberg
|
||||
|
||||
# ----------------------------------------------
|
||||
# Cloud Run: LibreOffice only
|
||||
# ----------------------------------------------
|
||||
FROM gotenberg-libreoffice AS gotenberg-cloudrun-libreoffice
|
||||
|
||||
USER root
|
||||
|
||||
RUN chown gotenberg: /usr/bin/tini
|
||||
|
||||
ENV API_PORT_FROM_ENV=PORT
|
||||
ENV LIBREOFFICE_AUTO_START=true
|
||||
ENV WEBHOOK_ENABLE_SYNC_MODE=true
|
||||
ENV GOTENBERG_BUILD_DEBUG_DATA=false
|
||||
ENV LOG_STD_ENABLE_GCP_FIELDS=true
|
||||
|
||||
USER gotenberg
|
||||
|
||||
# ==============================================================
|
||||
# AWS Lambda variants
|
||||
# ==============================================================
|
||||
|
||||
# ----------------------------------------------
|
||||
# AWS Lambda: full (Chromium + LibreOffice)
|
||||
# ----------------------------------------------
|
||||
FROM gotenberg AS gotenberg-aws-lambda
|
||||
|
||||
USER root
|
||||
|
||||
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
|
||||
|
||||
ENV AWS_LWA_PORT=3000
|
||||
ENV AWS_LWA_READINESS_CHECK_PATH=/health
|
||||
ENV AWS_LWA_INVOKE_MODE=buffered
|
||||
ENV API_PORT_FROM_ENV=AWS_LWA_PORT
|
||||
ENV WEBHOOK_ENABLE_SYNC_MODE=true
|
||||
ENV GOTENBERG_BUILD_DEBUG_DATA=false
|
||||
|
||||
USER gotenberg
|
||||
|
||||
# ----------------------------------------------
|
||||
# AWS Lambda: Chromium only
|
||||
# ----------------------------------------------
|
||||
FROM gotenberg-chromium AS gotenberg-aws-lambda-chromium
|
||||
|
||||
USER root
|
||||
|
||||
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
|
||||
|
||||
ENV AWS_LWA_PORT=3000
|
||||
ENV AWS_LWA_READINESS_CHECK_PATH=/health
|
||||
ENV AWS_LWA_INVOKE_MODE=buffered
|
||||
ENV API_PORT_FROM_ENV=AWS_LWA_PORT
|
||||
ENV WEBHOOK_ENABLE_SYNC_MODE=true
|
||||
ENV GOTENBERG_BUILD_DEBUG_DATA=false
|
||||
|
||||
USER gotenberg
|
||||
|
||||
# ----------------------------------------------
|
||||
# AWS Lambda: LibreOffice only
|
||||
# ----------------------------------------------
|
||||
FROM gotenberg-libreoffice AS gotenberg-aws-lambda-libreoffice
|
||||
|
||||
USER root
|
||||
|
||||
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
|
||||
|
||||
ENV AWS_LWA_PORT=3000
|
||||
ENV AWS_LWA_READINESS_CHECK_PATH=/health
|
||||
ENV AWS_LWA_INVOKE_MODE=buffered
|
||||
ENV API_PORT_FROM_ENV=AWS_LWA_PORT
|
||||
ENV WEBHOOK_ENABLE_SYNC_MODE=true
|
||||
ENV GOTENBERG_BUILD_DEBUG_DATA=false
|
||||
|
||||
USER gotenberg
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
ARG DOCKER_REGISTRY
|
||||
ARG DOCKER_REPOSITORY
|
||||
ARG GOTENBERG_VERSION
|
||||
|
||||
FROM $DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION
|
||||
|
||||
USER root
|
||||
|
||||
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
|
||||
|
||||
# AWS.
|
||||
ENV AWS_LWA_PORT=3000
|
||||
ENV AWS_LWA_READINESS_CHECK_PATH=/health
|
||||
ENV AWS_LWA_INVOKE_MODE=buffered
|
||||
|
||||
# Gotenberg.
|
||||
ENV API_PORT_FROM_ENV=AWS_LWA_PORT
|
||||
ENV WEBHOOK_ENABLE_SYNC_MODE=true
|
||||
ENV GOTENBERG_BUILD_DEBUG_DATA=false
|
||||
|
||||
USER gotenberg
|
||||
@@ -1,22 +0,0 @@
|
||||
ARG DOCKER_REGISTRY
|
||||
ARG DOCKER_REPOSITORY
|
||||
ARG GOTENBERG_VERSION
|
||||
|
||||
FROM $DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION
|
||||
|
||||
USER root
|
||||
|
||||
# For security reasons, the non-root user gotenberg does not own the Tini binary by default.
|
||||
# However, some providers like Cloud Run from Google Cloud cannot start a Docker container in that case.
|
||||
# See https://github.com/gotenberg/gotenberg/issues/90#issuecomment-543551353.
|
||||
RUN chown gotenberg: /usr/bin/tini
|
||||
|
||||
# Gotenberg.
|
||||
ENV API_PORT_FROM_ENV=PORT
|
||||
ENV CHROMIUM_AUTO_START=true
|
||||
ENV LIBREOFFICE_AUTO_START=true
|
||||
ENV WEBHOOK_ENABLE_SYNC_MODE=true
|
||||
ENV GOTENBERG_BUILD_DEBUG_DATA=false
|
||||
ENV LOG_ENABLE_GCP_FIELDS=true
|
||||
|
||||
USER gotenberg
|
||||
11
cmd/gotenberg-chromium/main.go
Normal file
11
cmd/gotenberg-chromium/main.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
gotenbergcmd "github.com/gotenberg/gotenberg/v8/cmd"
|
||||
// Gotenberg modules (Chromium variant — no LibreOffice).
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/standard/chromium"
|
||||
)
|
||||
|
||||
func main() {
|
||||
gotenbergcmd.Run()
|
||||
}
|
||||
11
cmd/gotenberg-libreoffice/main.go
Normal file
11
cmd/gotenberg-libreoffice/main.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
gotenbergcmd "github.com/gotenberg/gotenberg/v8/cmd"
|
||||
// Gotenberg modules (LibreOffice variant — no Chromium).
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/standard/libreoffice"
|
||||
)
|
||||
|
||||
func main() {
|
||||
gotenbergcmd.Run()
|
||||
}
|
||||
@@ -25,7 +25,7 @@ const banner = `
|
||||
\___/\___/\__/\__/_//_/_.__/\__/_/ \_, /
|
||||
/___/
|
||||
|
||||
A containerized API for seamless PDF conversion.
|
||||
A Docker-based API for converting documents to PDF.
|
||||
Version: %s
|
||||
-------------------------------------------------------
|
||||
`
|
||||
@@ -43,10 +43,24 @@ func Run() {
|
||||
fs.Bool("gotenberg-hide-banner", false, "Hide the banner")
|
||||
fs.Duration("gotenberg-graceful-shutdown-duration", time.Duration(30)*time.Second, "Set the graceful shutdown duration")
|
||||
fs.Bool("gotenberg-build-debug-data", true, "Set if build data is needed")
|
||||
fs.String("log-level", gotenberg.InfoLoggingLevel, fmt.Sprintf("Choose the level of logging detail. Options include %s, %s, %s, or %s", gotenberg.ErrorLoggingLevel, gotenberg.WarnLoggingLevel, gotenberg.InfoLoggingLevel, gotenberg.DebugLoggingLevel))
|
||||
fs.String("log-fields-prefix", "", "Prepend a specified prefix to each field in the logs")
|
||||
fs.String("log-std-format", gotenberg.AutoLoggingFormat, fmt.Sprintf("Specify the format of standard logging. Options include %s, %s, or %s", gotenberg.AutoLoggingFormat, gotenberg.JsonLoggingFormat, gotenberg.TextLoggingFormat))
|
||||
fs.Bool("log-std-enable-gcp-fields", false, "Enable Google Cloud Platform fields for standard logging - namely: time, message, severity")
|
||||
|
||||
// Logging & telemetry flags.
|
||||
fs.String("log-level", gotenberg.InfoLoggingLevel, "Set the log level")
|
||||
fs.String("log-fields-prefix", "", "Prepend a specified prefix to each log field key")
|
||||
fs.String("log-std-format", gotenberg.AutoLoggingFormat, "Set the log format for standard output")
|
||||
fs.Bool("log-std-enable-gcp-fields", false, "Use GCP-compatible field names in log output")
|
||||
|
||||
// Deprecated logging flags.
|
||||
fs.String("log-format", gotenberg.AutoLoggingFormat, "Set the log format")
|
||||
fs.Bool("log-enable-gcp-fields", false, "Use GCP-compatible field names")
|
||||
|
||||
if err := errors.Join(
|
||||
fs.MarkDeprecated("log-format", "use --log-std-format instead"),
|
||||
fs.MarkDeprecated("log-enable-gcp-fields", "use --log-std-enable-gcp-fields instead"),
|
||||
); err != nil {
|
||||
fmt.Printf("[FATAL] mark deprecated flags: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
descriptors := gotenberg.GetModuleDescriptors()
|
||||
var modsInfo strings.Builder
|
||||
@@ -80,10 +94,11 @@ func Run() {
|
||||
fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
f.Changed = true
|
||||
return
|
||||
}
|
||||
|
||||
err = f.Value.Set(val)
|
||||
err = fs.Set(f.Name, val)
|
||||
if err != nil {
|
||||
fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err)
|
||||
os.Exit(1)
|
||||
@@ -95,43 +110,43 @@ func Run() {
|
||||
hideBanner := parsedFlags.MustBool("gotenberg-hide-banner")
|
||||
gracefulShutdownDuration := parsedFlags.MustDuration("gotenberg-graceful-shutdown-duration")
|
||||
|
||||
// Initialize telemetry (logging + OTEL).
|
||||
serviceName := os.Getenv("OTEL_SERVICE_NAME")
|
||||
if serviceName == "" {
|
||||
serviceName = "gotenberg"
|
||||
}
|
||||
|
||||
telemetryConfig := gotenberg.TelemetryConfig{
|
||||
// OpenTelemetry.
|
||||
ServiceName: serviceName,
|
||||
ServiceVersion: Version,
|
||||
// Logging.
|
||||
LogLevel: parsedFlags.MustString("log-level"),
|
||||
telemetryCfg := gotenberg.TelemetryConfig{
|
||||
ServiceName: serviceName,
|
||||
ServiceVersion: Version,
|
||||
LogLevel: parsedFlags.MustDeprecatedString("log-format", "log-std-format"),
|
||||
LogFieldsPrefix: parsedFlags.MustString("log-fields-prefix"),
|
||||
LogStdFormat: parsedFlags.MustDeprecatedString("log-format", "log-std-format"),
|
||||
LogStdEnableGcpFields: parsedFlags.MustDeprecatedBool("log-enable-gcp-fields", "log-std-enable-gcp-fields"),
|
||||
}
|
||||
// LogLevel uses its own flag, not the format flag.
|
||||
telemetryCfg.LogLevel = parsedFlags.MustString("log-level")
|
||||
|
||||
err = telemetryCfg.Validate()
|
||||
if err != nil {
|
||||
fmt.Printf("[FATAL] invalid telemetry config: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
shutdownTelemetry, err := gotenberg.StartTelemetry(telemetryCfg)
|
||||
if err != nil {
|
||||
fmt.Printf("[FATAL] start telemetry: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if !hideBanner {
|
||||
fmt.Printf(banner, Version)
|
||||
}
|
||||
|
||||
err = telemetryConfig.Validate()
|
||||
if err != nil {
|
||||
fmt.Printf("[FATAL] telemetry: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Telemetry.
|
||||
shutdownTelemetry, err := gotenberg.StartTelemetry(telemetryConfig)
|
||||
if err != nil {
|
||||
fmt.Printf("[FATAL] telemetry: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Modules.
|
||||
fmt.Printf("[SYSTEM] modules: %s\n", modsInfo.String())
|
||||
|
||||
ctx := gotenberg.NewContext(parsedFlags, descriptors)
|
||||
|
||||
// Start application modules.
|
||||
apps, err := ctx.Modules(new(gotenberg.App))
|
||||
if err != nil {
|
||||
fmt.Printf("[FATAL] %s\n", err)
|
||||
@@ -223,16 +238,18 @@ func Run() {
|
||||
|
||||
err = eg.Wait()
|
||||
if err != nil {
|
||||
cancel()
|
||||
fmt.Printf("[FATAL] %v\n", err)
|
||||
os.Exit(1)
|
||||
os.Exit(1) //nolint:gocritic // defers are already called explicitly above
|
||||
}
|
||||
|
||||
// Shutdown telemetry (flush spans, metrics, logs).
|
||||
err = shutdownTelemetry(gracefulShutdownCtx)
|
||||
if err != nil {
|
||||
fmt.Printf("[FATAL] telemetry: %v\n", err)
|
||||
os.Exit(1)
|
||||
cancel()
|
||||
fmt.Printf("[FATAL] %v\n", err)
|
||||
os.Exit(1) //nolint:gocritic // defers are already called explicitly above
|
||||
}
|
||||
fmt.Printf("[SYSTEM] telemetry: shutdown success\n")
|
||||
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
31
compose.yaml
31
compose.yaml
@@ -33,12 +33,13 @@ services:
|
||||
- "--api-disable-download-from=${API_DISABLE_DOWNLOAD_FROM}"
|
||||
- "--api-disable-health-check-route-telemetry=${API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY}"
|
||||
- "--api-disable-root-route-telemetry=${API_DISABLE_ROOT_ROUTE_TELEMETRY}"
|
||||
- "--api-disable-version-route-telemetry=${API_DISABLE_VERSION_ROUTE_TELEMETRY}"
|
||||
- "--api-disable-debug-route-telemetry=${API_DISABLE_DEBUG_ROUTE_TELEMETRY}"
|
||||
- "--api-disable-version-route-telemetry=${API_DISABLE_VERSION_ROUTE_TELEMETRY}"
|
||||
- "--api-enable-debug-route=${API_ENABLE_DEBUG_ROUTE}"
|
||||
- "--chromium-restart-after=${CHROMIUM_RESTART_AFTER}"
|
||||
- "--chromium-auto-start=${CHROMIUM_AUTO_START}"
|
||||
- "--chromium-max-queue-size=${CHROMIUM_MAX_QUEUE_SIZE}"
|
||||
- "--chromium-idle-shutdown-timeout=${CHROMIUM_IDLE_SHUTDOWN_TIMEOUT}"
|
||||
- "--chromium-max-concurrency=${CHROMIUM_MAX_CONCURRENCY}"
|
||||
- "--chromium-start-timeout=${CHROMIUM_START_TIMEOUT}"
|
||||
- "--chromium-allow-insecure-localhost=${CHROMIUM_ALLOW_INSECURE_LOCALHOST}"
|
||||
@@ -55,8 +56,11 @@ services:
|
||||
- "--chromium-disable-routes=${CHROMIUM_DISABLE_ROUTES}"
|
||||
- "--libreoffice-restart-after=${LIBREOFFICE_RESTART_AFTER}"
|
||||
- "--libreoffice-max-queue-size=${LIBREOFFICE_MAX_QUEUE_SIZE}"
|
||||
- "--libreoffice-idle-shutdown-timeout=${LIBREOFFICE_IDLE_SHUTDOWN_TIMEOUT}"
|
||||
- "--libreoffice-auto-start=${LIBREOFFICE_AUTO_START}"
|
||||
- "--libreoffice-start-timeout=${LIBREOFFICE_START_TIMEOUT}"
|
||||
- "--libreoffice-allow-list=${LIBREOFFICE_ALLOW_LIST}"
|
||||
- "--libreoffice-deny-list=${LIBREOFFICE_DENY_LIST}"
|
||||
- "--libreoffice-disable-routes=${LIBREOFFICE_DISABLE_ROUTES}"
|
||||
- "--log-level=${LOG_LEVEL}"
|
||||
- "--log-fields-prefix=${LOG_FIELDS_PREFIX}"
|
||||
@@ -68,14 +72,23 @@ services:
|
||||
- "--pdfengines-convert-engines=${PDFENGINES_CONVERT_ENGINES}"
|
||||
- "--pdfengines-read-metadata-engines=${PDFENGINES_READ_METADATA_ENGINES}"
|
||||
- "--pdfengines-write-metadata-engines=${PDFENGINES_WRITE_METADATA_ENGINES}"
|
||||
- "--pdfengines-read-bookmarks-engines=${PDFENGINES_READ_BOOKMARKS_ENGINES}"
|
||||
- "--pdfengines-write-bookmarks-engines=${PDFENGINES_WRITE_BOOKMARKS_ENGINES}"
|
||||
- "--pdfengines-watermark-engines=${PDFENGINES_WATERMARK_ENGINES}"
|
||||
- "--pdfengines-stamp-engines=${PDFENGINES_STAMP_ENGINES}"
|
||||
- "--pdfengines-encrypt-engines=${PDFENGINES_ENCRYPT_ENGINES}"
|
||||
- "--pdfengines-rotate-engines=${PDFENGINES_ROTATE_ENGINES}"
|
||||
- "--pdfengines-embed-engines=${PDFENGINES_EMBED_ENGINES}"
|
||||
- "--pdfengines-embed-metadata-engines=${PDFENGINES_EMBED_METADATA_ENGINES}"
|
||||
- "--pdfengines-disable-routes=${PDFENGINES_DISABLE_ROUTES}"
|
||||
- "--pdfengines-add-attachments-engines=${PDFENGINES_ADD_ATTACHMENTS_ENGINES}"
|
||||
- "--prometheus-namespace=${PROMETHEUS_NAMESPACE}"
|
||||
- "--prometheus-collect-interval=${PROMETHEUS_COLLECT_INTERVAL}"
|
||||
- "--prometheus-disable-route-telemetry=${PROMETHEUS_DISABLE_ROUTE_TELEMETRY}"
|
||||
- "--prometheus-disable-collect=${PROMETHEUS_DISABLE_COLLECT}"
|
||||
- "--prometheus-metrics-path=${PROMETHEUS_METRICS_PATH}"
|
||||
- "--webhook-enable-sync-mode=${WEBHOOK_ENABLE_SYNC_MODE}"
|
||||
- "--webhook-allow-list=${WEBHOOK_ALLOW_LIST}"
|
||||
- "--webhook-deny-list=${WEBHOOK_DENY_LIST}"
|
||||
- "--webhook-error-allow-list=${WEBHOOK_ERROR_ALLOW_LIST}"
|
||||
- "--webhook-error-deny-list=${WEBHOOK_ERROR_DENY_LIST}"
|
||||
- "--webhook-max-retry=${WEBHOOK_MAX_RETRY}"
|
||||
- "--webhook-retry-min-wait=${WEBHOOK_RETRY_MIN_WAIT}"
|
||||
- "--webhook-retry-max-wait=${WEBHOOK_RETRY_MAX_WAIT}"
|
||||
@@ -90,14 +103,20 @@ services:
|
||||
ports:
|
||||
- "4317:4317" # OTLP gRPC receiver
|
||||
depends_on:
|
||||
- openobserve
|
||||
openobserve:
|
||||
condition: service_started
|
||||
restart: on-failure
|
||||
|
||||
openobserve:
|
||||
image: public.ecr.aws/zinclabs/openobserve:latest
|
||||
restart: always
|
||||
ports:
|
||||
- "5080:5080"
|
||||
- "5081:5081" # OTLP gRPC ingestion
|
||||
- "5081:5081"
|
||||
environment:
|
||||
ZO_ROOT_USER_EMAIL: telemetry@gotenberg.dev
|
||||
ZO_ROOT_USER_PASSWORD: telemetry
|
||||
|
||||
networks:
|
||||
default:
|
||||
enable_ipv6: false
|
||||
|
||||
122
go.mod
122
go.mod
@@ -1,41 +1,40 @@
|
||||
module github.com/gotenberg/gotenberg/v8
|
||||
|
||||
go 1.26.0
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/alexliesenfeld/health v0.8.1
|
||||
github.com/barasher/go-exiftool v1.10.0
|
||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
|
||||
github.com/chromedp/chromedp v0.14.2
|
||||
github.com/cucumber/godog v0.15.1
|
||||
github.com/dlclark/regexp2 v1.11.5
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
github.com/docker/go-connections v0.6.0
|
||||
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
|
||||
github.com/dlclark/regexp2 v1.12.0
|
||||
github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8
|
||||
github.com/labstack/echo/v4 v4.15.1
|
||||
github.com/labstack/gommon v0.4.2
|
||||
github.com/labstack/echo/v4 v4.15.2
|
||||
github.com/labstack/gommon v0.5.0
|
||||
github.com/mholt/archives v0.1.5
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/shirou/gopsutil/v4 v4.26.1
|
||||
github.com/moby/moby/api v1.54.2
|
||||
github.com/moby/moby/client v0.4.1
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/shirou/gopsutil/v4 v4.26.4
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/testcontainers/testcontainers-go v0.40.0
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0
|
||||
go.opentelemetry.io/otel v1.42.0
|
||||
go.opentelemetry.io/otel/log v0.18.0
|
||||
go.opentelemetry.io/otel/metric v1.42.0
|
||||
go.opentelemetry.io/otel/sdk v1.42.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.18.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0
|
||||
go.opentelemetry.io/otel/trace v1.42.0
|
||||
go.uber.org/multierr v1.11.0
|
||||
golang.org/x/net v0.51.0
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/term v0.40.0
|
||||
golang.org/x/text v0.34.0
|
||||
github.com/testcontainers/testcontainers-go v0.42.0
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0
|
||||
go.opentelemetry.io/otel v1.44.0
|
||||
go.opentelemetry.io/otel/log v0.20.0
|
||||
go.opentelemetry.io/otel/metric v1.44.0
|
||||
go.opentelemetry.io/otel/sdk v1.44.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0
|
||||
go.opentelemetry.io/otel/trace v1.44.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/text v0.37.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -43,11 +42,11 @@ require (
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/STARRY-S/zip v0.2.3 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bodgit/plumbing v1.3.0 // indirect
|
||||
github.com/bodgit/sevenzip v1.6.1 // indirect
|
||||
github.com/bodgit/sevenzip v1.6.4 // indirect
|
||||
github.com/bodgit/windows v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
@@ -62,11 +61,12 @@ require (
|
||||
github.com/cucumber/messages/go/v21 v21.0.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.7.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/ebitengine/purego v0.10.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
@@ -75,37 +75,34 @@ require (
|
||||
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/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
||||
github.com/hashicorp/go-memdb v1.3.5 // indirect
|
||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
||||
github.com/minio/minlz v1.0.1 // indirect
|
||||
github.com/minio/minlz v1.1.1 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.2.0 // indirect
|
||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||
github.com/moby/patternmatcher v0.6.1 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/morikuni/aec v1.1.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nwaples/rardecode/v2 v2.2.2 // indirect
|
||||
github.com/nwaples/rardecode/v2 v2.2.3 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.25 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.26 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/otlptranslator v1.0.0 // indirect
|
||||
@@ -113,35 +110,36 @@ require (
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/sorairolake/lzip-go v0.3.8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/stangelandcl/ppmd v0.1.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
google.golang.org/grpc v1.79.2 // indirect
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/grpc v1.81.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
259
go.sum
259
go.sum
@@ -10,18 +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/alexliesenfeld/health v0.8.1 h1:wdE3vt+cbJotiR8DGDBZPKHDFoJbAoWEfQTcqrmedUg=
|
||||
github.com/alexliesenfeld/health v0.8.1/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
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/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEXNMpIhs=
|
||||
github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo=
|
||||
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/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
|
||||
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
|
||||
github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4=
|
||||
github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8=
|
||||
github.com/bodgit/sevenzip v1.6.4 h1:iHiVJfxbrB6RF4X+snI2MpVgNBKmVfGaTqZGNlMQIU0=
|
||||
github.com/bodgit/sevenzip v1.6.4/go.mod h1:ZtNi5KNgHXeXg1G7WiF0IWSuFE2eG6lt/cTGlvuirO0=
|
||||
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
|
||||
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
@@ -47,8 +45,8 @@ github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7np
|
||||
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/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
||||
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
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/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0=
|
||||
github.com/cucumber/godog v0.15.1 h1:rb/6oHDdvVZKS66hrhpjFQFHjthFSrQBCOI1LwshNTI=
|
||||
@@ -61,25 +59,23 @@ 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/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/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
|
||||
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
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.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/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/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
|
||||
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
|
||||
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 h1:NZBJxCpbHS1gzS6xAmyxbJznosZIIPk9IB42v62UvKA=
|
||||
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -100,8 +96,8 @@ github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1
|
||||
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
|
||||
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df h1:Mwihr/o+v4L5h56rwHLOE20+hh7Okhwno5BHz3zDuao=
|
||||
github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
@@ -109,8 +105,8 @@ 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/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
@@ -134,8 +130,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
||||
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||
@@ -148,36 +144,38 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs=
|
||||
github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/labstack/echo/v4 v4.15.2 h1:nnh2sCzGCVYnU+wCisMPiYapEg/QVo/gcI9ePKg5/T4=
|
||||
github.com/labstack/echo/v4 v4.15.2/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI=
|
||||
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
|
||||
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
|
||||
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
|
||||
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/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/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
|
||||
github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A=
|
||||
github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
|
||||
github.com/minio/minlz v1.1.1 h1:OGmft1V6AnI/Wme332U6bhG54nxEan+VFgkD7lat4KM=
|
||||
github.com/minio/minlz v1.1.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
|
||||
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
|
||||
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
|
||||
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
|
||||
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
@@ -186,22 +184,18 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
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/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
|
||||
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nwaples/rardecode/v2 v2.2.2 h1:/5oL8dzYivRM/tqX9VcTSWfbpwcbwKG1QtSJr3b3KcU=
|
||||
github.com/nwaples/rardecode/v2 v2.2.2/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
|
||||
github.com/nwaples/rardecode/v2 v2.2.3 h1:qaVuy3ChZDbAQZshPLjHeNJKF3Cru8uo9jmgveKIy2A=
|
||||
github.com/nwaples/rardecode/v2 v2.2.3/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
|
||||
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
|
||||
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
@@ -219,8 +213,8 @@ github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4Ul
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo=
|
||||
github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc=
|
||||
github.com/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY=
|
||||
github.com/shirou/gopsutil/v4 v4.26.4/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/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=
|
||||
@@ -232,24 +226,25 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
|
||||
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/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stangelandcl/ppmd v0.1.1 h1:c25QazhlWUn5nmR1QOzafKhQxBicAr7GGCKER2aJ8H8=
|
||||
github.com/stangelandcl/ppmd v0.1.1/go.mod h1:Rrv7M+/2P5jYr/GMLhBl7Ug3uJ1bUiVzr5LbbaV6xgY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
|
||||
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
|
||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
|
||||
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
|
||||
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
||||
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||
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.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
@@ -263,90 +258,90 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
|
||||
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/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0 h1:yOYhGNPZseueTTvWp5iBD3/CthrmvayUXYEX862dDi4=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0/go.mod h1:CvaNVqIfcybc+7xqZNubbE+26K6P7AKZF/l0lE2kdCk=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 h1:dkBzNEAIKADEaFnuESzcXvpd09vxvDZsOjx11gjUqLk=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0/go.mod h1:Z5RIwRkZgauOIfnG5IpidvLpERjhTninpP1dTG2jTl4=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 h1:4fnRcNpc6YFtG3zsFw9achKn3XgmxPxuMuqIL5rE8e8=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0/go.mod h1:qTvIHMFKoxW7HXg02gm6/Wofhq5p3Ib/A/NNt1EoBSQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
|
||||
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 h1:deI9UQMoGFgrg5iLPgzueqFPHevDl+28YKfSpPTI6rY=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0/go.mod h1:PFx9NgpNUKXdf7J4Q3agRxMs3Y07QhTCVipKmLsMKnU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 h1:icqq3Z34UrEFk2u+HMhTtRsvo7Ues+eiJVjaJt62njs=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0/go.mod h1:W2m8P+d5Wn5kipj4/xmbt9uMqezEKfBjzVJadfABSBE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 h1:H7O6RlGOMTizyl3R08Kn5pdM06bnH8oscSj7o11tmLA=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0/go.mod h1:mBFWu/WOVDkWWsR7Tx7h6EpQB8wsv7P0Yrh0Pb7othc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 h1:KJVjPD3rcPb98rIs3HznyJlrfx9ge5oJvxxlGR+P/7s=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0/go.mod h1:K3kRa2ckmHWQaTWQdPRHc7qGXASuVuoEQXzrvlA98Ws=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs=
|
||||
go.opentelemetry.io/otel/log v0.18.0 h1:XgeQIIBjZZrliksMEbcwMZefoOSMI1hdjiLEiiB0bAg=
|
||||
go.opentelemetry.io/otel/log v0.18.0/go.mod h1:KEV1kad0NofR3ycsiDH4Yjcoj0+8206I6Ox2QYFSNgI=
|
||||
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
|
||||
go.opentelemetry.io/otel/sdk/log v0.18.0 h1:n8OyZr7t7otkeTnPTbDNom6rW16TBYGtvyy2Gk6buQw=
|
||||
go.opentelemetry.io/otel/sdk/log v0.18.0/go.mod h1:C0+wxkTwKpOCZLrlJ3pewPiiQwpzycPI/u6W0Z9fuYk=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0 h1:l3mYuPsuBx6UKE47BVcPrZoZ0q/KER57vbj2qkgDLXA=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0/go.mod h1:7cHtiVJpZebB3wybTa4NG+FUo5NPe3PROz1FqB0+qdw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
|
||||
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 h1:5RgvxieNq9tS3ewrV1vnODvbHPfKUIJcYtF9Cvz+6aQ=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0/go.mod h1:iTBIdNwx/xmUhfgJs6+84S4dIK059811cO1eUBjKcHY=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0 h1:saQoWg5845Q8TojpqeVStS7zGwVZ6bc5W2PJavTPiBM=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0/go.mod h1:AAaS6xs5AyqMdR3Ir0nSWK+QudL2XM8Vbw5INzUxNc8=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0 h1:R3jsCoTIzv0BiYNhW0axyswn/6SMJ8xL1OuGxvni1Kw=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0/go.mod h1:m07gqyr2QhQxKOKb5vqKCCBtLH3uqlNYR7PU/FISXVU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
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.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0/go.mod h1:MP4eemTiI9zC8fgg+DYynhYDYf3ba72S376TvP+Ye0Q=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0 h1:vkrK8PAznv2NKt2r+kdu252ccGzkEqLc2aSXbQIALYQ=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0/go.mod h1:V/UB6D3vMF/UBOL5igAsAYnk1nG/bzYYTzvsB16cy7o=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 h1:aZfdmtI6QU/DAPD4b7YZ5zuJgewxO1EW9miOZklqleU=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0/go.mod h1:isNl10/Om5CBWu9jj8WOb2+tJLbCVXDgqwzCaJMnJ6w=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0=
|
||||
go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs=
|
||||
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY=
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
|
||||
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -357,3 +352,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
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/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||
|
||||
22
package-lock.json
generated
22
package-lock.json
generated
@@ -5,7 +5,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"prettier": "3.8.1",
|
||||
"prettier": "3.8.3",
|
||||
"prettier-plugin-gherkin": "^3.1.3",
|
||||
"prettier-plugin-sh": "^0.18.0"
|
||||
}
|
||||
@@ -34,9 +34,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@reteps/dockerfmt": {
|
||||
"version": "0.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.3.6.tgz",
|
||||
"integrity": "sha512-Tb5wIMvBf/nLejTQ61krK644/CEMB/cpiaIFXqGApfGqO3GwcR3qnI0DbmkFVCl2OyEp8LnLX3EkucoL0+tbFg==",
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.5.2.tgz",
|
||||
"integrity": "sha512-Hbr7yen4fP5TxGM54ucXa4o5NwWXatJ6Bd9I8gp0PValYbI4Rug2Gu+rVv7K7o/efQc3F5ctqWJz47rYaa8zBw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -58,9 +58,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
|
||||
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -86,13 +86,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier-plugin-sh": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.18.0.tgz",
|
||||
"integrity": "sha512-cW1XL27FOJQ/qGHOW6IHwdCiNWQsAgK+feA8V6+xUTaH0cD3Mh+tFAtBvEEWvuY6hTDzRV943Fzeii+qMOh7nQ==",
|
||||
"version": "0.18.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.18.1.tgz",
|
||||
"integrity": "sha512-uZmU22wBMevjh3rmCatNQqiEer2+5KLa0xYCBX6zQQUQkcNzVL+s6FbPKK6ZSUNUbQk6jMAcQHrYPvuL2W6ihQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reteps/dockerfmt": "^0.3.6",
|
||||
"@reteps/dockerfmt": "^0.5.1",
|
||||
"sh-syntax": "^0.5.8"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"prettier": "3.8.1",
|
||||
"prettier": "3.8.3",
|
||||
"prettier-plugin-gherkin": "^3.1.3",
|
||||
"prettier-plugin-sh": "^0.18.0"
|
||||
}
|
||||
|
||||
@@ -55,6 +55,13 @@ func CommandContext(ctx context.Context, logger *slog.Logger, binPath string, ar
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetEnv replaces the environment variables passed to the underlying
|
||||
// process. When SetEnv is not called, the process inherits the parent's
|
||||
// environment.
|
||||
func (cmd *Cmd) SetEnv(env []string) {
|
||||
cmd.process.Env = env
|
||||
}
|
||||
|
||||
// Start starts the command but does not wait for its completion.
|
||||
func (cmd *Cmd) Start() error {
|
||||
err := cmd.pipeOutput()
|
||||
@@ -62,7 +69,7 @@ func (cmd *Cmd) Start() error {
|
||||
return fmt.Errorf("pipe unix process output: %w", err)
|
||||
}
|
||||
|
||||
cmd.logger.DebugContext(cmd.ctx, fmt.Sprintf("start unix process: %s", strings.Join(cmd.process.Args, " ")))
|
||||
cmd.logger.DebugContext(context.Background(), fmt.Sprintf("start unix process: %s", strings.Join(cmd.process.Args, " ")))
|
||||
|
||||
err = cmd.process.Start()
|
||||
if err != nil {
|
||||
@@ -109,7 +116,7 @@ func (cmd *Cmd) Exec() (int, error) {
|
||||
case err = <-errChan:
|
||||
errProc := cmd.Kill()
|
||||
if errProc != nil {
|
||||
cmd.logger.ErrorContext(cmd.ctx, errProc.Error())
|
||||
cmd.logger.ErrorContext(context.Background(), errProc.Error())
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
@@ -124,7 +131,7 @@ func (cmd *Cmd) Exec() (int, error) {
|
||||
case <-cmd.ctx.Done():
|
||||
errProc := cmd.Kill()
|
||||
if errProc != nil {
|
||||
cmd.logger.ErrorContext(cmd.ctx, errProc.Error())
|
||||
cmd.logger.ErrorContext(context.Background(), errProc.Error())
|
||||
}
|
||||
|
||||
return 62, fmt.Errorf("context done: %w", cmd.ctx.Err())
|
||||
@@ -134,7 +141,7 @@ func (cmd *Cmd) Exec() (int, error) {
|
||||
// pipeOutput creates logs entries according to the process stdout and stderr.
|
||||
// It does nothing if the logging level is not debug.
|
||||
func (cmd *Cmd) pipeOutput() error {
|
||||
if !cmd.logger.Enabled(cmd.ctx, slog.LevelDebug) {
|
||||
if !cmd.logger.Enabled(context.Background(), slog.LevelDebug) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -155,7 +162,7 @@ func (cmd *Cmd) pipeOutput() error {
|
||||
defer func(reader io.ReadCloser) {
|
||||
err := reader.Close()
|
||||
if err != nil && !strings.Contains(err.Error(), "file already closed") {
|
||||
logger.ErrorContext(cmd.ctx, fmt.Sprintf("close reader: %s", err))
|
||||
logger.ErrorContext(context.Background(), fmt.Sprintf("close reader: %s", err))
|
||||
}
|
||||
}(reader)
|
||||
|
||||
@@ -163,14 +170,14 @@ func (cmd *Cmd) pipeOutput() error {
|
||||
line, _, err := r.ReadLine()
|
||||
if err != nil {
|
||||
if err != io.EOF && !strings.Contains(err.Error(), "file already closed") {
|
||||
logger.ErrorContext(cmd.ctx, fmt.Sprintf("pipe unix process output error: %s", err))
|
||||
logger.ErrorContext(context.Background(), fmt.Sprintf("pipe unix process output error: %s", err))
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if len(line) != 0 {
|
||||
logger.DebugContext(cmd.ctx, string(line))
|
||||
logger.DebugContext(context.Background(), string(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,13 +201,13 @@ func (cmd *Cmd) Kill() error {
|
||||
|
||||
err := syscall.Kill(-cmd.process.Process.Pid, syscall.SIGKILL)
|
||||
if err == nil {
|
||||
cmd.logger.DebugContext(cmd.ctx, "unix process killed")
|
||||
cmd.logger.DebugContext(context.Background(), "unix process killed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// If the process does not exist anymore, the error is irrelevant.
|
||||
if strings.Contains(err.Error(), "no such process") {
|
||||
cmd.logger.DebugContext(cmd.ctx, "unix process already killed")
|
||||
cmd.logger.DebugContext(context.Background(), "unix process already killed")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package gotenberg provides most of the logic of the module system.
|
||||
// Package gotenberg implements the core module system.
|
||||
//
|
||||
// caddyserver/caddy, licensed under the Apache License 2.0, has significantly
|
||||
// inspired this module system.
|
||||
// Significantly inspired by caddyserver/caddy, licensed under the Apache
|
||||
// License 2.0.
|
||||
//
|
||||
// More details are available on https://caddyserver.com/.
|
||||
// See https://caddyserver.com/.
|
||||
package gotenberg
|
||||
|
||||
@@ -12,40 +12,57 @@ import (
|
||||
// ErrFiltered happens if a value is filtered by the [FilterDeadline] function.
|
||||
var ErrFiltered = errors.New("value filtered")
|
||||
|
||||
// FilterDeadline checks if given value is allowed and not denied according to
|
||||
// regex patterns. It returns a [context.DeadlineExceeded] if it takes too long
|
||||
// to process.
|
||||
func FilterDeadline(allowed, denied *regexp2.Regexp, s string, deadline time.Time) error {
|
||||
// FIXME: not ideal to compile everytime, but is there another way to create a clone?
|
||||
if allowed.String() != "" {
|
||||
allow := regexp2.MustCompile(allowed.String(), 0)
|
||||
allow.MatchTimeout = time.Until(deadline)
|
||||
// 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
|
||||
|
||||
ok, err := allow.MatchString(s)
|
||||
if err != nil {
|
||||
if time.Now().After(deadline) {
|
||||
return context.DeadlineExceeded
|
||||
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
|
||||
}
|
||||
return fmt.Errorf("'%s' cannot handle '%s': %w", allow.String(), s, err)
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("'%s' does not match the expression from the allowed list: %w", s, ErrFiltered)
|
||||
|
||||
if !matched {
|
||||
return fmt.Errorf("'%s' does not match any expression from the allowed list: %w", s, ErrFiltered)
|
||||
}
|
||||
}
|
||||
|
||||
if denied.String() != "" {
|
||||
deny := regexp2.MustCompile(denied.String(), 0)
|
||||
deny.MatchTimeout = time.Until(deadline)
|
||||
if len(denied) > 0 {
|
||||
for _, pattern := range denied {
|
||||
clone := regexp2.MustCompile(pattern.String(), 0)
|
||||
clone.MatchTimeout = time.Until(deadline)
|
||||
|
||||
ok, err := deny.MatchString(s)
|
||||
if err != nil {
|
||||
if time.Now().After(deadline) {
|
||||
return context.DeadlineExceeded
|
||||
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 fmt.Errorf("'%s' cannot handle '%s': %w", deny.String(), s, err)
|
||||
}
|
||||
if ok {
|
||||
return fmt.Errorf("'%s' matches the expression from the denied list: %w", s, ErrFiltered)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
func TestFilterDeadline(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
allowed *regexp2.Regexp
|
||||
denied *regexp2.Regexp
|
||||
allowed []*regexp2.Regexp
|
||||
denied []*regexp2.Regexp
|
||||
s string
|
||||
deadline time.Time
|
||||
expectError bool
|
||||
@@ -21,17 +21,17 @@ func TestFilterDeadline(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
scenario: "DeadlineExceeded (allowed)",
|
||||
allowed: regexp2.MustCompile("foo", 0),
|
||||
denied: regexp2.MustCompile("", 0),
|
||||
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)",
|
||||
allowed: regexp2.MustCompile("foo", 0),
|
||||
denied: regexp2.MustCompile("", 0),
|
||||
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,
|
||||
@@ -39,8 +39,8 @@ func TestFilterDeadline(t *testing.T) {
|
||||
},
|
||||
{
|
||||
scenario: "DeadlineExceeded (denied)",
|
||||
allowed: regexp2.MustCompile("", 0),
|
||||
denied: regexp2.MustCompile("foo", 0),
|
||||
allowed: nil,
|
||||
denied: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)},
|
||||
s: "foo",
|
||||
deadline: time.Now().Add(time.Duration(-1) * time.Hour),
|
||||
expectError: true,
|
||||
@@ -48,21 +48,55 @@ func TestFilterDeadline(t *testing.T) {
|
||||
},
|
||||
{
|
||||
scenario: "ErrFiltered (denied)",
|
||||
allowed: regexp2.MustCompile("", 0),
|
||||
denied: regexp2.MustCompile("foo", 0),
|
||||
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",
|
||||
allowed: regexp2.MustCompile("", 0),
|
||||
denied: regexp2.MustCompile("", 0),
|
||||
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)
|
||||
|
||||
@@ -222,3 +222,32 @@ func (f *ParsedFlags) MustDeprecatedRegexp(deprecated string, newName string) *r
|
||||
|
||||
return f.MustRegexp(newName)
|
||||
}
|
||||
|
||||
// MustRegexpSlice returns a slice of compiled regular expressions from a
|
||||
// string-slice flag given by name. Empty strings are skipped.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustRegexpSlice(name string) []*regexp2.Regexp {
|
||||
vals := f.MustStringSlice(name)
|
||||
|
||||
var regexps []*regexp2.Regexp
|
||||
for _, val := range vals {
|
||||
if val == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
regexps = append(regexps, regexp2.MustCompile(val, 0))
|
||||
}
|
||||
|
||||
return regexps
|
||||
}
|
||||
|
||||
// MustDeprecatedRegexpSlice returns the slice of compiled regular expressions
|
||||
// of a deprecated flag if it was explicitly set or the slice of the new flag.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustDeprecatedRegexpSlice(deprecated string, newName string) []*regexp2.Regexp {
|
||||
if f.Changed(deprecated) {
|
||||
return f.MustRegexpSlice(deprecated)
|
||||
}
|
||||
|
||||
return f.MustRegexpSlice(newName)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
flag "github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
@@ -833,3 +834,120 @@ func TestParsedFlags_MustDeprecatedRegexp(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustRegexpSlice(t *testing.T) {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.StringSlice("foo", []string{}, "")
|
||||
|
||||
err := fs.Parse([]string{"--foo=^file:.*", "--foo=^ftp://.*"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
name string
|
||||
expectPanic bool
|
||||
expectLen int
|
||||
}{
|
||||
{
|
||||
scenario: "success with multiple patterns",
|
||||
name: "foo",
|
||||
expectPanic: false,
|
||||
expectLen: 2,
|
||||
},
|
||||
{
|
||||
scenario: "non-existing flag",
|
||||
name: "bar",
|
||||
expectPanic: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Fatal("expected panic but got none")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if !tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("expected no panic but got: %v", r)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
result := parsedFlags.MustRegexpSlice(tc.name)
|
||||
|
||||
if !tc.expectPanic && len(result) != tc.expectLen {
|
||||
t.Errorf("expected %d regexps but got %d", tc.expectLen, len(result))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Test empty strings are skipped.
|
||||
fs2 := flag.NewFlagSet("tests2", flag.ContinueOnError)
|
||||
fs2.StringSlice("baz", []string{""}, "")
|
||||
|
||||
err = fs2.Parse([]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
parsedFlags2 := ParsedFlags{FlagSet: fs2}
|
||||
result := parsedFlags2.MustRegexpSlice("baz")
|
||||
if len(result) != 0 {
|
||||
t.Errorf("expected 0 regexps for empty strings but got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustDeprecatedRegexpSlice(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
rawFlags []string
|
||||
expectPattern string
|
||||
}{
|
||||
{
|
||||
scenario: "deprecated flag value",
|
||||
rawFlags: []string{"--foo=^file:.*"},
|
||||
expectPattern: "^file:.*",
|
||||
},
|
||||
{
|
||||
scenario: "non-deprecated flag value",
|
||||
rawFlags: []string{"--bar=^ftp://.*"},
|
||||
expectPattern: "^ftp://.*",
|
||||
},
|
||||
{
|
||||
scenario: "deprecated flag value > non-deprecated flag value",
|
||||
rawFlags: []string{"--foo=^file:.*", "--bar=^ftp://.*"},
|
||||
expectPattern: "^file:.*",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.StringSlice("foo", []string{}, "")
|
||||
fs.StringSlice("bar", []string{}, "")
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
err := parsedFlags.Parse(tc.rawFlags)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
actual := parsedFlags.MustDeprecatedRegexpSlice("foo", "bar")
|
||||
if len(actual) != 1 {
|
||||
t.Fatalf("expected 1 regexp but got %d", len(actual))
|
||||
}
|
||||
if actual[0].String() != tc.expectPattern {
|
||||
t.Errorf("expected pattern '%s' but got '%s'", tc.expectPattern, actual[0].String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
_ = regexp2.None // Keep import alive.
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func GarbageCollect(ctx context.Context, logger *slog.Logger, rootPath string, i
|
||||
|
||||
for _, substr := range includeSubstr {
|
||||
if (strings.Contains(info.Name(), substr) || path == substr) && info.ModTime().Before(expirationTime) {
|
||||
err := os.RemoveAll(path)
|
||||
err := os.RemoveAll(path) //nolint:gosec // G122: rootPath is a trusted internal working directory
|
||||
if err != nil {
|
||||
return fmt.Errorf("garbage collect '%s': %w", path, err)
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestGarbageCollect(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
err := GarbageCollect(context.Background(), slog.New(slog.NewJSONHandler(os.Stdout, nil)), tc.rootPath, tc.includeSubstr, time.Now())
|
||||
err := GarbageCollect(context.Background(), slog.New(slog.DiscardHandler), tc.rootPath, tc.includeSubstr, time.Now())
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
|
||||
@@ -71,16 +71,17 @@ func NewStdHandler(level slog.Level, format string, fieldsPrefix string, enableG
|
||||
// Configure level encoding based on format and GCP settings.
|
||||
if a.Key == slog.LevelKey {
|
||||
l := a.Value.Any().(slog.Level)
|
||||
if format == textLoggingFormat && isTerminal {
|
||||
switch {
|
||||
case format == textLoggingFormat && isTerminal:
|
||||
if enableGcpFields {
|
||||
a.Value = slog.StringValue(gcpSeverityColorEncoder(l))
|
||||
} else {
|
||||
a.Value = slog.StringValue(levelToColor(l).Add(l.String()))
|
||||
}
|
||||
} else if enableGcpFields && format != textLoggingFormat {
|
||||
case enableGcpFields && format != textLoggingFormat:
|
||||
a.Key = "severity"
|
||||
a.Value = slog.StringValue(gcpSeverity(l))
|
||||
} else {
|
||||
default:
|
||||
a.Value = slog.StringValue(strings.ToLower(l.String()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// Package otel gathers initialization utilities for OpenTelemetry
|
||||
// instrumentation.
|
||||
//
|
||||
// This package has been significantly inspired by
|
||||
// https://github.com/lucavallin/gotel.
|
||||
// Significantly inspired by https://github.com/lucavallin/gotel.
|
||||
//
|
||||
// See: https://opentelemetry.io/.
|
||||
// See https://opentelemetry.io/.
|
||||
package otel
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
"go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
|
||||
)
|
||||
|
||||
// InitTracerProvider initializes the OpenTelemetry tracer provider.
|
||||
|
||||
1
pkg/gotenberg/logging.go
Normal file
1
pkg/gotenberg/logging.go
Normal file
@@ -0,0 +1 @@
|
||||
package gotenberg
|
||||
26
pkg/gotenberg/metrics.go
Normal file
26
pkg/gotenberg/metrics.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package gotenberg
|
||||
|
||||
// Metric represents a unitary metric.
|
||||
type Metric struct {
|
||||
// Name is the unique identifier.
|
||||
// Required.
|
||||
Name string
|
||||
|
||||
// Description describes the metric.
|
||||
// Optional.
|
||||
Description string
|
||||
|
||||
// Read returns the current value.
|
||||
// Required.
|
||||
Read func() float64
|
||||
}
|
||||
|
||||
// MetricsProvider is a module interface which provides a list of [Metric].
|
||||
//
|
||||
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
|
||||
// provider, _ := ctx.Module(new(gotenberg.MetricsProvider))
|
||||
// metrics, _ := provider.(gotenberg.MetricsProvider).Metrics()
|
||||
// }
|
||||
type MetricsProvider interface {
|
||||
Metrics() ([]Metric, error)
|
||||
}
|
||||
@@ -45,14 +45,21 @@ func (mod *DebuggableMock) Debug() map[string]any {
|
||||
//
|
||||
//nolint:dupl
|
||||
type PdfEngineMock struct {
|
||||
MergeMock func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
|
||||
SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
|
||||
FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath string) error
|
||||
ConvertMock func(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
|
||||
ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
|
||||
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
|
||||
AddAttachmentsMock func(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
|
||||
MergeMock func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
|
||||
SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
|
||||
FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath string) error
|
||||
ConvertMock func(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
|
||||
ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
|
||||
PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
|
||||
WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
|
||||
ReadBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error)
|
||||
EncryptMock func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
|
||||
EmbedFilesMock func(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
|
||||
EmbedFilesMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error
|
||||
WriteBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
|
||||
WatermarkMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
|
||||
StampMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
|
||||
RotateMock func(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
||||
@@ -75,16 +82,44 @@ func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *slog.Logg
|
||||
return engine.ReadMetadataMock(ctx, logger, inputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
|
||||
return engine.PageCountMock(ctx, logger, inputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
|
||||
return engine.WriteMetadataMock(ctx, logger, metadata, inputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error) {
|
||||
return engine.ReadBookmarksMock(ctx, logger, inputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
|
||||
return engine.EncryptMock(ctx, logger, inputPath, userPassword, ownerPassword)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) AddAttachments(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
||||
return engine.AddAttachmentsMock(ctx, logger, filePaths, inputPath)
|
||||
func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
||||
return engine.EmbedFilesMock(ctx, logger, filePaths, inputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
|
||||
return engine.EmbedFilesMetadataMock(ctx, logger, metadata, inputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error {
|
||||
return engine.WriteBookmarksMock(ctx, logger, inputPath, bookmarks)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error {
|
||||
return engine.WatermarkMock(ctx, logger, inputPath, stamp)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error {
|
||||
return engine.StampMock(ctx, logger, inputPath, stamp)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
|
||||
return engine.RotateMock(ctx, logger, inputPath, angle, pages)
|
||||
}
|
||||
|
||||
// PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface.
|
||||
@@ -122,8 +157,8 @@ type ProcessSupervisorMock struct {
|
||||
HealthyMock func() bool
|
||||
RunMock func(ctx context.Context, logger *slog.Logger, task func() error) error
|
||||
ReqQueueSizeMock func() int64
|
||||
ActiveTasksCountMock func() int64
|
||||
RestartsCountMock func() int64
|
||||
ActiveTasksCountMock func() int64
|
||||
}
|
||||
|
||||
func (s *ProcessSupervisorMock) Launch() error {
|
||||
@@ -146,12 +181,21 @@ func (s *ProcessSupervisorMock) ReqQueueSize() int64 {
|
||||
return s.ReqQueueSizeMock()
|
||||
}
|
||||
|
||||
func (s *ProcessSupervisorMock) RestartsCount() int64 {
|
||||
return s.RestartsCountMock()
|
||||
}
|
||||
|
||||
func (s *ProcessSupervisorMock) ActiveTasksCount() int64 {
|
||||
return s.ActiveTasksCountMock()
|
||||
}
|
||||
|
||||
func (s *ProcessSupervisorMock) RestartsCount() int64 {
|
||||
return s.RestartsCountMock()
|
||||
// MetricsProviderMock is a mock for the [MetricsProvider] interface.
|
||||
type MetricsProviderMock struct {
|
||||
MetricsMock func() ([]Metric, error)
|
||||
}
|
||||
|
||||
func (provider *MetricsProviderMock) Metrics() ([]Metric, error) {
|
||||
return provider.MetricsMock()
|
||||
}
|
||||
|
||||
// MkdirAllMock is a mock for the [MkdirAll] interface.
|
||||
@@ -180,6 +224,7 @@ var (
|
||||
_ PdfEngineProvider = (*PdfEngineProviderMock)(nil)
|
||||
_ Process = (*ProcessMock)(nil)
|
||||
_ ProcessSupervisor = (*ProcessSupervisorMock)(nil)
|
||||
_ MetricsProvider = (*MetricsProviderMock)(nil)
|
||||
_ MkdirAll = (*MkdirAllMock)(nil)
|
||||
_ PathRename = (*PathRenameMock)(nil)
|
||||
)
|
||||
|
||||
454
pkg/gotenberg/outbound.go
Normal file
454
pkg/gotenberg/outbound.go
Normal file
@@ -0,0 +1,454 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
)
|
||||
|
||||
// ErrNonPublicIP indicates that an outbound URL targets an IP address that
|
||||
// is not reachable on the public internet. This covers loopback, RFC1918
|
||||
// private, link-local, unspecified, multicast, and IPv6 unique-local
|
||||
// (fc00::/7) addresses, as well as their IPv4-mapped IPv6 wrappers (for
|
||||
// example [::ffff:127.0.0.1]).
|
||||
var ErrNonPublicIP = errors.New("non-public IP")
|
||||
|
||||
// ErrPublicIP indicates that an outbound URL targets an IP address that is
|
||||
// reachable on the public internet. It is returned when a caller opts
|
||||
// into denying public destinations via [WithDenyPublicIPs]; typical use
|
||||
// cases are air-gapped or data-governed deployments where Gotenberg must
|
||||
// only talk to hosts on a private network.
|
||||
var ErrPublicIP = errors.New("public IP")
|
||||
|
||||
// netipResolver is the subset of [net.Resolver] used by [resolveHost].
|
||||
// Defining it as an interface allows tests to substitute a stub resolver.
|
||||
type netipResolver interface {
|
||||
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
|
||||
}
|
||||
|
||||
// outboundResolver is the resolver used by [resolveHost]. It is a
|
||||
// package-level variable so that tests can substitute a stub resolver.
|
||||
var outboundResolver netipResolver = net.DefaultResolver
|
||||
|
||||
// outboundDialer is the underlying dialer used by [secureDialContext]. It is
|
||||
// a package-level variable so that tests can replace it.
|
||||
var outboundDialer = &net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}
|
||||
|
||||
// nonPublicIPv6Prefixes lists IPv6 ranges that the standard library does
|
||||
// not classify via [netip.Addr] helpers but that must not be considered
|
||||
// public:
|
||||
//
|
||||
// - 2002::/16 6to4 (RFC 3056, deprecated by RFC 7526). Bits 16-47
|
||||
// embed an IPv4 destination, including private ones.
|
||||
// - 2001::/32 Teredo (RFC 4380). Bits 96-127 embed an IPv4
|
||||
// destination, including private ones.
|
||||
// - 64:ff9b::/96 NAT64 well-known prefix (RFC 6052). Low 32 bits
|
||||
// embed an IPv4 destination translated by a NAT64 gateway.
|
||||
// - 64:ff9b:1::/48 NAT64 local-use prefix (RFC 8215). Same risk.
|
||||
// - fec0::/10 Deprecated site-local (RFC 3879). Not covered by
|
||||
// [netip.Addr.IsPrivate] which only handles fc00::/7.
|
||||
// - ::/96 IPv4-compatible IPv6 (deprecated). Embeds an IPv4
|
||||
// destination and is not handled by [netip.Addr.Unmap].
|
||||
// - 2001:db8::/32 Documentation range (RFC 3849). Never routable.
|
||||
// - 100::/64 Discard prefix (RFC 6666).
|
||||
var nonPublicIPv6Prefixes = []netip.Prefix{
|
||||
netip.MustParsePrefix("2002::/16"),
|
||||
netip.MustParsePrefix("2001::/32"),
|
||||
netip.MustParsePrefix("64:ff9b::/96"),
|
||||
netip.MustParsePrefix("64:ff9b:1::/48"),
|
||||
netip.MustParsePrefix("fec0::/10"),
|
||||
netip.MustParsePrefix("::/96"),
|
||||
netip.MustParsePrefix("2001:db8::/32"),
|
||||
netip.MustParsePrefix("100::/64"),
|
||||
}
|
||||
|
||||
// IsPublicIP reports whether addr is reachable on the public internet. It
|
||||
// returns false for loopback, private (RFC1918), link-local, unspecified,
|
||||
// multicast, and unique-local addresses. IPv4-mapped IPv6 addresses are
|
||||
// unmapped before evaluation so that [::ffff:127.0.0.1] is correctly
|
||||
// identified as loopback.
|
||||
//
|
||||
// IPv6 prefixes that tunnel or translate to an embedded IPv4 destination
|
||||
// (6to4, Teredo, NAT64) are rejected wholesale rather than recursed into,
|
||||
// because a host that routes them implicitly trusts the IPv4 mapping and
|
||||
// the prefixes themselves are deprecated or translation-only. See
|
||||
// [nonPublicIPv6Prefixes] for the full list and rationale.
|
||||
func IsPublicIP(addr netip.Addr) bool {
|
||||
if !addr.IsValid() {
|
||||
return false
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
switch {
|
||||
case addr.IsLoopback(),
|
||||
addr.IsPrivate(),
|
||||
addr.IsLinkLocalUnicast(),
|
||||
addr.IsLinkLocalMulticast(),
|
||||
addr.IsMulticast(),
|
||||
addr.IsUnspecified(),
|
||||
addr.IsInterfaceLocalMulticast():
|
||||
return false
|
||||
}
|
||||
if addr.Is6() {
|
||||
for _, p := range nonPublicIPv6Prefixes {
|
||||
if p.Contains(addr) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ResolveAndCheckPublic resolves host and rejects any resolved address
|
||||
// that fails [IsPublicIP] with [ErrNonPublicIP]. It is the strict
|
||||
// equivalent of [DecideOutbound] with [WithDenyPrivateIPs] true for a
|
||||
// bare host. Callers that need a different policy should use
|
||||
// [DecideOutbound] directly.
|
||||
func ResolveAndCheckPublic(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
return resolveHost(ctx, host, true, false)
|
||||
}
|
||||
|
||||
// resolveHost resolves host and returns the addresses. When denyPrivate
|
||||
// is true, a non-public address is rejected with [ErrNonPublicIP]. When
|
||||
// denyPublic is true, a public address is rejected with [ErrPublicIP].
|
||||
// Both checks may be active at the same time, in which case any
|
||||
// resolved address fails and the caller must rely on an allow-list
|
||||
// bypass.
|
||||
func resolveHost(ctx context.Context, host string, denyPrivate, denyPublic bool) ([]netip.Addr, error) {
|
||||
if host == "" {
|
||||
return nil, errors.New("empty host")
|
||||
}
|
||||
|
||||
check := func(a netip.Addr) error {
|
||||
public := IsPublicIP(a)
|
||||
if denyPublic && public {
|
||||
return fmt.Errorf("%q: %w", a, ErrPublicIP)
|
||||
}
|
||||
if denyPrivate && !public {
|
||||
return fmt.Errorf("%q: %w", a, ErrNonPublicIP)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if addr, err := netip.ParseAddr(host); err == nil {
|
||||
if err := check(addr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []netip.Addr{addr}, nil
|
||||
}
|
||||
addrs, err := outboundResolver.LookupNetIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve %q: %w", host, err)
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return nil, fmt.Errorf("resolve %q: no addresses returned", host)
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if err := check(a); err != nil {
|
||||
return nil, fmt.Errorf("%q resolves to rejected address %w", host, err)
|
||||
}
|
||||
}
|
||||
return addrs, nil
|
||||
}
|
||||
|
||||
// OutboundDecision is the result of validating an outbound URL via
|
||||
// [DecideOutbound]. Callers use it to dial the destination either directly
|
||||
// (operator-approved allow-list match, Bypass true) or via [DialPinned] so
|
||||
// that the connect targets the IPs resolved at validation time. Passing
|
||||
// the decision to the dialer closes the window between validation and
|
||||
// connect that DNS rebinding exploits.
|
||||
type OutboundDecision struct {
|
||||
// Bypass is true when an allow-list pattern matched the URL. The
|
||||
// operator has explicitly opted into the destination; the caller
|
||||
// should dial directly without an additional IP check.
|
||||
Bypass bool
|
||||
|
||||
// Pinned holds the IPs resolved for the URL host. The caller should
|
||||
// dial one of these via [DialPinned] to prevent DNS rebinding between
|
||||
// validation and connect.
|
||||
Pinned []netip.Addr
|
||||
}
|
||||
|
||||
// outboundDecisionKey is the context key under which an [OutboundDecision]
|
||||
// is stored.
|
||||
type outboundDecisionKey struct{}
|
||||
|
||||
// decideConfig carries optional settings for [DecideOutbound] and
|
||||
// [FilterOutboundURL]. See [DecideOption] for how callers configure it.
|
||||
type decideConfig struct {
|
||||
denyPrivateIPs bool
|
||||
denyPublicIPs bool
|
||||
}
|
||||
|
||||
// DecideOption customizes how [DecideOutbound] and [FilterOutboundURL]
|
||||
// validate a URL. Options are applied in order on top of the permissive
|
||||
// defaults (no IP-class rejection).
|
||||
type DecideOption func(*decideConfig)
|
||||
|
||||
// WithDenyPrivateIPs rejects URLs whose host resolves to a non-public IP
|
||||
// address (loopback, RFC1918, link-local, unique-local, multicast,
|
||||
// unspecified). DNS still runs and the returned [OutboundDecision] still
|
||||
// carries the resolved IPs for dial pinning, so enabling or disabling
|
||||
// this option does not affect DNS-rebinding protection. Use it on
|
||||
// internet-exposed deployments to mitigate SSRF against internal
|
||||
// services.
|
||||
func WithDenyPrivateIPs(deny bool) DecideOption {
|
||||
return func(c *decideConfig) { c.denyPrivateIPs = deny }
|
||||
}
|
||||
|
||||
// WithDenyPublicIPs rejects URLs whose host resolves to a public IP
|
||||
// address. Use it on air-gapped or data-governed deployments where
|
||||
// Gotenberg must only reach hosts on a private network; the option
|
||||
// prevents data exfiltration to attacker-controlled public servers via
|
||||
// webhook callbacks, downloadFrom URLs, or user-supplied stamp sources.
|
||||
// May be combined with [WithDenyPrivateIPs]; in that case every resolved
|
||||
// address fails and only an allow-list bypass permits a destination.
|
||||
func WithDenyPublicIPs(deny bool) DecideOption {
|
||||
return func(c *decideConfig) { c.denyPublicIPs = deny }
|
||||
}
|
||||
|
||||
// httpLikeScheme reports whether scheme is one of http, https, ws, or wss.
|
||||
// Only these schemes go through the IP-based address check; data, blob,
|
||||
// file, and other schemes are filtered by the regex layer alone.
|
||||
func httpLikeScheme(scheme string) bool {
|
||||
switch scheme {
|
||||
case "http", "https", "ws", "wss":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DecideOutbound parses rawURL, runs the regex allow/deny lists against
|
||||
// the normalized form, and (when no allow-list match) resolves the host
|
||||
// and applies the IP-class checks selected by opts. It returns the
|
||||
// resulting [OutboundDecision] so the caller can pin the dial to the IPs
|
||||
// that were resolved here and skip a second DNS lookup later, which
|
||||
// closes the DNS rebinding window that affects callers that only receive
|
||||
// an error from [FilterOutboundURL].
|
||||
//
|
||||
// The semantics:
|
||||
//
|
||||
// 1. The URL is parsed and its scheme and host lowercased.
|
||||
// 2. allowList and denyList apply against the normalized form with OR
|
||||
// semantics. The deny-list always applies.
|
||||
// 3. For http, https, ws, and wss, the host is resolved and every
|
||||
// resolved address must satisfy the enabled IP-class checks
|
||||
// ([WithDenyPrivateIPs], [WithDenyPublicIPs]). An allow-list match
|
||||
// bypasses the IP-class checks and the returned decision carries
|
||||
// Bypass true. Otherwise the decision carries Pinned with the
|
||||
// resolved addresses.
|
||||
//
|
||||
// Callers that dial the destination themselves must honor Bypass and
|
||||
// Pinned: bypassed URLs dial the hostname directly (operator opt-in);
|
||||
// pinned URLs must dial one of Pinned via [DialPinned].
|
||||
func DecideOutbound(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time, opts ...DecideOption) (OutboundDecision, error) {
|
||||
cfg := decideConfig{}
|
||||
for _, opt := range opts {
|
||||
opt(&cfg)
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return OutboundDecision{}, fmt.Errorf("parse URL %q: %w", rawURL, ErrFiltered)
|
||||
}
|
||||
parsed.Scheme = strings.ToLower(parsed.Scheme)
|
||||
parsed.Host = strings.ToLower(parsed.Host)
|
||||
normalized := parsed.String()
|
||||
|
||||
allowMatched := false
|
||||
if len(allowList) > 0 {
|
||||
for _, pattern := range allowList {
|
||||
clone := regexp2.MustCompile(pattern.String(), 0)
|
||||
clone.MatchTimeout = time.Until(deadline)
|
||||
|
||||
ok, err := clone.MatchString(normalized)
|
||||
if err != nil {
|
||||
if time.Now().After(deadline) {
|
||||
return OutboundDecision{}, context.DeadlineExceeded
|
||||
}
|
||||
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
|
||||
}
|
||||
|
||||
if ok {
|
||||
allowMatched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !allowMatched {
|
||||
return OutboundDecision{}, fmt.Errorf("'%s' does not match any expression from the allowed list: %w", normalized, ErrFiltered)
|
||||
}
|
||||
}
|
||||
|
||||
for _, pattern := range denyList {
|
||||
clone := regexp2.MustCompile(pattern.String(), 0)
|
||||
clone.MatchTimeout = time.Until(deadline)
|
||||
|
||||
ok, err := clone.MatchString(normalized)
|
||||
if err != nil {
|
||||
if time.Now().After(deadline) {
|
||||
return OutboundDecision{}, context.DeadlineExceeded
|
||||
}
|
||||
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
|
||||
}
|
||||
|
||||
if ok {
|
||||
return OutboundDecision{}, fmt.Errorf("'%s' matches the expression from the denied list: %w", normalized, ErrFiltered)
|
||||
}
|
||||
}
|
||||
|
||||
if allowMatched {
|
||||
return OutboundDecision{Bypass: true}, nil
|
||||
}
|
||||
|
||||
if !httpLikeScheme(parsed.Scheme) {
|
||||
return OutboundDecision{}, nil
|
||||
}
|
||||
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
return OutboundDecision{}, fmt.Errorf("URL %q has no host: %w", rawURL, ErrFiltered)
|
||||
}
|
||||
|
||||
addrs, err := resolveHost(ctx, host, cfg.denyPrivateIPs, cfg.denyPublicIPs)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ErrNonPublicIP):
|
||||
return OutboundDecision{}, fmt.Errorf("'%s' targets a non-public address: %w", normalized, ErrFiltered)
|
||||
case errors.Is(err, ErrPublicIP):
|
||||
return OutboundDecision{}, fmt.Errorf("'%s' targets a public address: %w", normalized, ErrFiltered)
|
||||
default:
|
||||
return OutboundDecision{}, fmt.Errorf("validate '%s' host: %w", normalized, err)
|
||||
}
|
||||
}
|
||||
|
||||
return OutboundDecision{Pinned: addrs}, nil
|
||||
}
|
||||
|
||||
// FilterOutboundURL validates that rawURL is acceptable for an outbound
|
||||
// request from Gotenberg. It is the URL-aware replacement for
|
||||
// [FilterDeadline] and should be preferred for any new code that filters
|
||||
// a URL before issuing or instructing an outbound request.
|
||||
//
|
||||
// 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
|
||||
// opt in via [WithDenyPrivateIPs] or [WithDenyPublicIPs]. The deny-list
|
||||
// always applies and cannot be bypassed by an allow-list match.
|
||||
func FilterOutboundURL(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time, opts ...DecideOption) error {
|
||||
_, err := DecideOutbound(ctx, rawURL, allowList, denyList, deadline, opts...)
|
||||
return err
|
||||
}
|
||||
|
||||
// outboundRoundTripper is an [http.RoundTripper] that validates each
|
||||
// request URL via [DecideOutbound] and stashes the resulting
|
||||
// [OutboundDecision] in the request context so that [secureDialContext]
|
||||
// can pin the dial or bypass the IP check as appropriate. Because the
|
||||
// http.Client invokes RoundTrip again for each redirect hop, this also
|
||||
// re-validates redirect targets without a separate CheckRedirect.
|
||||
type outboundRoundTripper struct {
|
||||
base http.RoundTripper
|
||||
allowList []*regexp2.Regexp
|
||||
denyList []*regexp2.Regexp
|
||||
opts []DecideOption
|
||||
}
|
||||
|
||||
// RoundTrip validates req.URL and delegates to the base transport.
|
||||
func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
deadline, ok := req.Context().Deadline()
|
||||
if !ok {
|
||||
deadline = time.Now().Add(30 * time.Second)
|
||||
}
|
||||
|
||||
decision, err := DecideOutbound(req.Context(), req.URL.String(), rt.allowList, rt.denyList, deadline, rt.opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx := context.WithValue(req.Context(), outboundDecisionKey{}, decision)
|
||||
return rt.base.RoundTrip(req.WithContext(ctx))
|
||||
}
|
||||
|
||||
// NewOutboundHttpClient returns an [http.Client] that validates every
|
||||
// outbound request URL via the same logic as [FilterOutboundURL] and
|
||||
// pins the resulting dial to the resolved IPs.
|
||||
//
|
||||
// The client re-validates redirect targets automatically because the
|
||||
// underlying [http.Client] invokes the wrapping [http.RoundTripper] once
|
||||
// per hop. This closes the redirect-based SSRF bypass that affects raw
|
||||
// [http.Client] usage when no CheckRedirect is set.
|
||||
//
|
||||
// The default posture is permissive; callers pass [WithDenyPrivateIPs]
|
||||
// or [WithDenyPublicIPs] to opt into IP-class rejection.
|
||||
func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2.Regexp, opts ...DecideOption) *http.Client {
|
||||
base := http.DefaultTransport.(*http.Transport).Clone()
|
||||
base.DialContext = secureDialContext
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &outboundRoundTripper{
|
||||
base: base,
|
||||
allowList: allowList,
|
||||
denyList: denyList,
|
||||
opts: opts,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// secureDialContext consumes the [OutboundDecision] stashed in ctx by
|
||||
// [outboundRoundTripper]. When the decision is to bypass (allow-list
|
||||
// match), it dials directly. When the decision contains pinned IPs, it
|
||||
// dials each in turn until one connects. When no decision is present
|
||||
// (the dialer was used outside of [outboundRoundTripper]), it falls back
|
||||
// to resolving the destination without IP-class checks so that the
|
||||
// fallback matches the permissive default and operators who need
|
||||
// restrictions configure them at the caller.
|
||||
func secureDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("split host:port %q: %w", addr, err)
|
||||
}
|
||||
|
||||
if decision, ok := ctx.Value(outboundDecisionKey{}).(OutboundDecision); ok {
|
||||
if decision.Bypass {
|
||||
return outboundDialer.DialContext(ctx, network, addr)
|
||||
}
|
||||
if len(decision.Pinned) > 0 {
|
||||
return DialPinned(ctx, network, decision.Pinned, port)
|
||||
}
|
||||
}
|
||||
|
||||
addrs, err := resolveHost(ctx, host, false, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return DialPinned(ctx, network, addrs, port)
|
||||
}
|
||||
|
||||
// DialPinned dials each addr in turn until one connects, returning the
|
||||
// first successful connection or the last error. Callers pass the Pinned
|
||||
// slice from [OutboundDecision] so that the dial targets exactly the IPs
|
||||
// that [DecideOutbound] resolved, preventing DNS rebinding between
|
||||
// validation and connect.
|
||||
func DialPinned(ctx context.Context, network string, addrs []netip.Addr, port string) (net.Conn, error) {
|
||||
var lastErr error
|
||||
for _, a := range addrs {
|
||||
conn, err := outboundDialer.DialContext(ctx, network, net.JoinHostPort(a.String(), port))
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if lastErr == nil {
|
||||
return nil, errors.New("no addresses to dial")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
489
pkg/gotenberg/outbound_test.go
Normal file
489
pkg/gotenberg/outbound_test.go
Normal file
@@ -0,0 +1,489 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
)
|
||||
|
||||
func TestIsPublicIP(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
addr string
|
||||
public bool
|
||||
}{
|
||||
// Public.
|
||||
{"1.1.1.1", true},
|
||||
{"8.8.8.8", true},
|
||||
{"2606:4700:4700::1111", true},
|
||||
|
||||
// Loopback.
|
||||
{"127.0.0.1", false},
|
||||
{"127.255.255.254", false},
|
||||
{"::1", false},
|
||||
|
||||
// IPv4-mapped IPv6 (Issue 2).
|
||||
{"::ffff:127.0.0.1", false},
|
||||
{"::ffff:10.0.0.1", false},
|
||||
{"::ffff:169.254.169.254", false},
|
||||
|
||||
// RFC1918.
|
||||
{"10.0.0.1", false},
|
||||
{"172.16.0.1", false},
|
||||
{"172.31.255.254", false},
|
||||
{"192.168.1.1", false},
|
||||
|
||||
// Link-local.
|
||||
{"169.254.169.254", false},
|
||||
{"fe80::1", false},
|
||||
|
||||
// Unique-local.
|
||||
{"fc00::1", false},
|
||||
{"fd12:3456:789a::1", false},
|
||||
|
||||
// Unspecified.
|
||||
{"0.0.0.0", false},
|
||||
{"::", false},
|
||||
|
||||
// Multicast.
|
||||
{"224.0.0.1", false},
|
||||
{"ff02::1", false},
|
||||
|
||||
// 6to4 wrapping internal/private IPv4 (RFC 3056, deprecated by
|
||||
// RFC 7526). a9fe:a9fe = 169.254.169.254 (cloud metadata).
|
||||
{"2002:a9fe:a9fe::", false},
|
||||
{"2002:0a00:0001::", false},
|
||||
{"2002:c0a8:0101::", false},
|
||||
|
||||
// 6to4 wrapping a public IPv4 (8.8.8.8) is rejected wholesale.
|
||||
{"2002:0808:0808::", false},
|
||||
|
||||
// NAT64 well-known prefix (RFC 6052).
|
||||
{"64:ff9b::a9fe:a9fe", false},
|
||||
{"64:ff9b::0808:0808", false},
|
||||
|
||||
// NAT64 local-use prefix (RFC 8215).
|
||||
{"64:ff9b:1::a9fe:a9fe", false},
|
||||
|
||||
// Teredo (RFC 4380).
|
||||
{"2001:0:abcd:ef12:3456:7890:a9fe:a9fe", false},
|
||||
|
||||
// Deprecated site-local (RFC 3879).
|
||||
{"fec0::1", false},
|
||||
{"feff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", false},
|
||||
|
||||
// IPv4-compatible IPv6 (deprecated, not handled by Unmap).
|
||||
{"::a9fe:a9fe", false},
|
||||
|
||||
// Documentation prefix (RFC 3849).
|
||||
{"2001:db8::1", false},
|
||||
|
||||
// Discard prefix (RFC 6666).
|
||||
{"100::1", false},
|
||||
} {
|
||||
t.Run(tc.addr, func(t *testing.T) {
|
||||
addr, err := netip.ParseAddr(tc.addr)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", tc.addr, err)
|
||||
}
|
||||
if got := IsPublicIP(addr); got != tc.public {
|
||||
t.Fatalf("IsPublicIP(%q) = %v, want %v", tc.addr, got, tc.public)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// stubResolver lets tests fake DNS lookups in [ResolveAndCheckPublic].
|
||||
type stubResolver struct {
|
||||
lookup func(host string) ([]netip.Addr, error)
|
||||
}
|
||||
|
||||
func (s stubResolver) LookupNetIP(_ context.Context, _, host string) ([]netip.Addr, error) {
|
||||
return s.lookup(host)
|
||||
}
|
||||
|
||||
func withStubResolver(t *testing.T, fn func(host string) ([]netip.Addr, error)) {
|
||||
t.Helper()
|
||||
prev := outboundResolver
|
||||
outboundResolver = stubResolver{lookup: fn}
|
||||
t.Cleanup(func() { outboundResolver = prev })
|
||||
}
|
||||
|
||||
func mustAddrs(t *testing.T, ss ...string) []netip.Addr {
|
||||
t.Helper()
|
||||
out := make([]netip.Addr, 0, len(ss))
|
||||
for _, s := range ss {
|
||||
a, err := netip.ParseAddr(s)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", s, err)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestFilterOutboundURL(t *testing.T) {
|
||||
defaultDeny := []*regexp2.Regexp{
|
||||
regexp2.MustCompile(`^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)`, 0),
|
||||
}
|
||||
chromiumDeny := []*regexp2.Regexp{
|
||||
regexp2.MustCompile(`^file:(?!//\/tmp/).*`, 0),
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
rawURL string
|
||||
allow []*regexp2.Regexp
|
||||
deny []*regexp2.Regexp
|
||||
opts []DecideOption
|
||||
stub func(host string) ([]netip.Addr, error)
|
||||
expectErr bool
|
||||
expectIs error
|
||||
expectErrMsg string
|
||||
}{
|
||||
{
|
||||
scenario: "public IP literal passes",
|
||||
rawURL: "https://1.1.1.1/",
|
||||
deny: defaultDeny,
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
scenario: "loopback IP literal blocked by default deny-list",
|
||||
rawURL: "http://127.0.0.1:8080/",
|
||||
deny: defaultDeny,
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "Issue 4: uppercase scheme normalized then blocked by deny-list",
|
||||
rawURL: "HTTP://127.0.0.1:8080/",
|
||||
deny: defaultDeny,
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "Issue 2: IPv4-mapped IPv6 evades deny-list but blocked by IP check",
|
||||
rawURL: "http://[::ffff:127.0.0.1]:8080/page.pdf",
|
||||
deny: defaultDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "Issue 2: IPv4-mapped IPv6 to RFC1918 blocked by IP check",
|
||||
rawURL: "http://[::ffff:10.0.0.1]/",
|
||||
deny: defaultDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "hostname resolving to public IP passes with deny-private-ips",
|
||||
rawURL: "https://example.com/",
|
||||
deny: defaultDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "93.184.216.34"), nil },
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
scenario: "hostname resolving to loopback blocked with deny-private-ips",
|
||||
rawURL: "https://rebind.example/",
|
||||
deny: defaultDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "127.0.0.1"), nil },
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "hostname resolving to mixed public+private blocked with deny-private-ips",
|
||||
rawURL: "https://mixed.example/",
|
||||
deny: defaultDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "1.1.1.1", "10.0.0.1"), nil },
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "allow-list match bypasses IP check",
|
||||
rawURL: "http://internal.service/api",
|
||||
allow: []*regexp2.Regexp{regexp2.MustCompile(`^http://internal\.service`, 0)},
|
||||
deny: defaultDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
scenario: "deny-list still wins over allow-list match",
|
||||
rawURL: "http://internal.service/api",
|
||||
allow: []*regexp2.Regexp{regexp2.MustCompile(`^http://internal`, 0)},
|
||||
deny: []*regexp2.Regexp{regexp2.MustCompile(`/api$`, 0)},
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "allow-list non-empty and no match rejects",
|
||||
rawURL: "https://other.example/",
|
||||
allow: []*regexp2.Regexp{regexp2.MustCompile(`^https://allowed\.example`, 0)},
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "file:// allowed under tmp passes Chromium default",
|
||||
rawURL: "file:///tmp/index.html",
|
||||
deny: chromiumDeny,
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
scenario: "file:// outside tmp blocked by Chromium default",
|
||||
rawURL: "file:///etc/passwd",
|
||||
deny: chromiumDeny,
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "Chromium default permissive passes http to public host",
|
||||
rawURL: "https://example.com/",
|
||||
deny: chromiumDeny,
|
||||
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "93.184.216.34"), nil },
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
scenario: "Chromium with deny-private-ips blocks http to loopback",
|
||||
rawURL: "http://127.0.0.1:3000/health",
|
||||
deny: chromiumDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "Chromium with deny-private-ips blocks cloud metadata",
|
||||
rawURL: "http://169.254.169.254/latest/meta-data/",
|
||||
deny: chromiumDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "data: URL passes (non-network scheme)",
|
||||
rawURL: "data:text/html;base64,PGgxPmhpPC9oMT4=",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
scenario: "URL with no host rejected",
|
||||
rawURL: "http:///path",
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "userinfo cannot mask host when deny-private-ips enabled",
|
||||
rawURL: "http://example.com@127.0.0.1/",
|
||||
deny: defaultDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.stub != nil {
|
||||
withStubResolver(t, tc.stub)
|
||||
} else {
|
||||
// Default: any DNS lookup in a non-stubbed test is a bug.
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
t.Fatalf("unexpected DNS lookup for %q", host)
|
||||
return nil, nil
|
||||
})
|
||||
}
|
||||
|
||||
err := FilterOutboundURL(context.Background(), tc.rawURL, tc.allow, tc.deny, time.Now().Add(5*time.Second), tc.opts...)
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if tc.expectIs != nil && !errors.Is(err, tc.expectIs) {
|
||||
t.Fatalf("expected error to wrap %v, got: %v", tc.expectIs, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAndCheckPublic_IPLiteralLoopback(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
t.Fatalf("unexpected DNS lookup for %q", host)
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
_, err := ResolveAndCheckPublic(context.Background(), "127.0.0.1")
|
||||
if !errors.Is(err, ErrNonPublicIP) {
|
||||
t.Fatalf("expected ErrNonPublicIP, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAndCheckPublic_HostResolvesToLoopback(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
return mustAddrs(t, "127.0.0.1"), nil
|
||||
})
|
||||
|
||||
_, err := ResolveAndCheckPublic(context.Background(), "rebind.example")
|
||||
if !errors.Is(err, ErrNonPublicIP) {
|
||||
t.Fatalf("expected ErrNonPublicIP, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAndCheckPublic_HostResolvesToPublic(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
return mustAddrs(t, "1.1.1.1"), nil
|
||||
})
|
||||
|
||||
addrs, err := ResolveAndCheckPublic(context.Background(), "example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(addrs) != 1 || addrs[0].String() != "1.1.1.1" {
|
||||
t.Fatalf("expected [1.1.1.1], got: %v", addrs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideOutbound_DenyPrivateIPs_RejectsLoopbackLiteral(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://127.0.0.1:8080/",
|
||||
nil, nil,
|
||||
time.Now().Add(5*time.Second),
|
||||
WithDenyPrivateIPs(true),
|
||||
)
|
||||
if !errors.Is(err, ErrFiltered) {
|
||||
t.Fatalf("WithDenyPrivateIPs(true) must reject loopback literal, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideOutbound_DenyPrivateIPs_AllowsPublic(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
return mustAddrs(t, "93.184.216.34"), nil
|
||||
})
|
||||
|
||||
decision, err := DecideOutbound(
|
||||
context.Background(),
|
||||
"http://example.com/",
|
||||
nil, nil,
|
||||
time.Now().Add(5*time.Second),
|
||||
WithDenyPrivateIPs(true),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for public host, got: %v", err)
|
||||
}
|
||||
if len(decision.Pinned) != 1 || decision.Pinned[0].String() != "93.184.216.34" {
|
||||
t.Fatalf("decision.Pinned = %v, want [93.184.216.34]", decision.Pinned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideOutbound_DenyPublicIPs_RejectsPublic(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
return mustAddrs(t, "1.1.1.1"), nil
|
||||
})
|
||||
|
||||
_, err := DecideOutbound(
|
||||
context.Background(),
|
||||
"http://example.com/",
|
||||
nil, nil,
|
||||
time.Now().Add(5*time.Second),
|
||||
WithDenyPublicIPs(true),
|
||||
)
|
||||
if !errors.Is(err, ErrFiltered) {
|
||||
t.Fatalf("WithDenyPublicIPs(true) must reject public host, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideOutbound_DenyPublicIPs_AllowsPrivate(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
return mustAddrs(t, "10.0.0.5"), nil
|
||||
})
|
||||
|
||||
decision, err := DecideOutbound(
|
||||
context.Background(),
|
||||
"http://internal.svc/",
|
||||
nil, nil,
|
||||
time.Now().Add(5*time.Second),
|
||||
WithDenyPublicIPs(true),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for private host, got: %v", err)
|
||||
}
|
||||
if len(decision.Pinned) != 1 || decision.Pinned[0].String() != "10.0.0.5" {
|
||||
t.Fatalf("decision.Pinned = %v, want [10.0.0.5]", decision.Pinned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideOutbound_DenyBoth_WhitelistOnly(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
return mustAddrs(t, "1.1.1.1"), nil
|
||||
})
|
||||
|
||||
// Both denies active and no allow-list match: every resolved address
|
||||
// fails. Only an allow-list match can permit a destination under
|
||||
// this posture.
|
||||
_, err := DecideOutbound(
|
||||
context.Background(),
|
||||
"http://example.com/",
|
||||
nil, nil,
|
||||
time.Now().Add(5*time.Second),
|
||||
WithDenyPrivateIPs(true),
|
||||
WithDenyPublicIPs(true),
|
||||
)
|
||||
if !errors.Is(err, ErrFiltered) {
|
||||
t.Fatalf("expected ErrFiltered with both denies enabled, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideOutbound_DenyLists_WinOverDenyPrivateIPs(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
t.Fatalf("unexpected DNS lookup for %q", host)
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
// The regex deny-list fires before any resolution; verifies that
|
||||
// operator-supplied deny patterns remain effective regardless of
|
||||
// IP-class options.
|
||||
deny := []*regexp2.Regexp{regexp2.MustCompile(`^http://evil\.`, 0)}
|
||||
|
||||
_, err := DecideOutbound(
|
||||
context.Background(),
|
||||
"http://evil.local/",
|
||||
nil, deny,
|
||||
time.Now().Add(5*time.Second),
|
||||
WithDenyPrivateIPs(true),
|
||||
)
|
||||
if !errors.Is(err, ErrFiltered) {
|
||||
t.Fatalf("deny-list must still reject, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideOutbound_Permissive_AllowsPrivate(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
return mustAddrs(t, "10.0.0.5"), nil
|
||||
})
|
||||
|
||||
// No options passed: default posture is permissive across both
|
||||
// IP classes. The caller still gets pinned IPs for dial safety.
|
||||
decision, err := DecideOutbound(
|
||||
context.Background(),
|
||||
"http://internal.svc/",
|
||||
nil, nil,
|
||||
time.Now().Add(5*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("permissive default must allow private host, got: %v", err)
|
||||
}
|
||||
if len(decision.Pinned) != 1 || decision.Pinned[0].String() != "10.0.0.5" {
|
||||
t.Fatalf("decision.Pinned = %v, want [10.0.0.5]", decision.Pinned)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,14 @@ var (
|
||||
// ErrPdfEncryptionNotSupported is returned when encryption
|
||||
// is not supported by the PDF engine.
|
||||
ErrPdfEncryptionNotSupported = errors.New("encryption not supported")
|
||||
|
||||
// ErrPdfStampSourceNotSupported is returned when a stamp source type
|
||||
// is not supported by the PDF engine.
|
||||
ErrPdfStampSourceNotSupported = errors.New("stamp source not supported")
|
||||
|
||||
// ErrPdfRotateAngleNotSupported is returned when the rotation angle is
|
||||
// not supported.
|
||||
ErrPdfRotateAngleNotSupported = errors.New("rotation angle not supported")
|
||||
)
|
||||
|
||||
// PdfEngineInvalidArgsError represents an error returned by a PDF engine when
|
||||
@@ -48,6 +56,33 @@ func NewPdfEngineInvalidArgs(engine, msg string) error {
|
||||
return &PdfEngineInvalidArgsError{engine, msg}
|
||||
}
|
||||
|
||||
const (
|
||||
// StampSourceText represents a text-based stamp source.
|
||||
StampSourceText string = "text"
|
||||
|
||||
// StampSourceImage represents an image-based stamp source.
|
||||
StampSourceImage string = "image"
|
||||
|
||||
// StampSourcePDF represents a PDF-based stamp source.
|
||||
StampSourcePDF string = "pdf"
|
||||
)
|
||||
|
||||
// Stamp gathers the data required to apply a watermark or stamp to a PDF.
|
||||
type Stamp struct {
|
||||
// Source is one of "text", "image", or "pdf".
|
||||
Source string
|
||||
|
||||
// Expression is the text content (for text source) or file path (for
|
||||
// image/pdf source).
|
||||
Expression string
|
||||
|
||||
// Pages is the optional page range to apply the stamp to.
|
||||
Pages string
|
||||
|
||||
// Options holds engine-specific styling options.
|
||||
Options map[string]string
|
||||
}
|
||||
|
||||
const (
|
||||
// SplitModeIntervals represents a mode where a PDF is split at specific
|
||||
// intervals.
|
||||
@@ -108,6 +143,14 @@ type PdfFormats struct {
|
||||
PdfUa bool
|
||||
}
|
||||
|
||||
// Bookmark represents a node in the PDF document's outline
|
||||
// (table of contents).
|
||||
type Bookmark struct {
|
||||
Title string `json:"title"`
|
||||
Page int `json:"page"`
|
||||
Children []Bookmark `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// PdfEngine provides an interface for operations on PDFs. Implementations
|
||||
// can use various tools like PDFtk, or implement functionality directly in
|
||||
// Go.
|
||||
@@ -134,18 +177,45 @@ type PdfEngine interface {
|
||||
// ReadMetadata extracts the metadata of a given PDF file.
|
||||
ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
|
||||
|
||||
// PageCount returns the number of pages in a PDF file.
|
||||
PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
|
||||
|
||||
// WriteMetadata writes the metadata into a given PDF file.
|
||||
WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
|
||||
|
||||
// ReadBookmarks reads the document outline (bookmarks) of a PDF file.
|
||||
ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error)
|
||||
|
||||
// WriteBookmarks adds a document outline (bookmarks) to a PDF file.
|
||||
// The bookmarks parameter represents the hierarchical tree of the outline.
|
||||
WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
|
||||
|
||||
// Encrypt adds password protection to a PDF file.
|
||||
// The userPassword is required to open the document.
|
||||
// The ownerPassword provides full access to the document.
|
||||
// If the ownerPassword is empty, it defaults to the userPassword.
|
||||
Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
|
||||
|
||||
// AddAttachments adds attachments into a PDF. All files are attached as
|
||||
// file attachments without modifying the main PDF content.
|
||||
AddAttachments(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
|
||||
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
|
||||
// without modifying the main PDF content.
|
||||
// TODO: attachments instead? Rename the route?
|
||||
EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
|
||||
|
||||
// EmbedFilesMetadata sets metadata (such as MIME type and AFRelationship)
|
||||
// on already-embedded files in a PDF. The metadata map is keyed by
|
||||
// filename, with each value being a map of property names to values
|
||||
// (e.g., "mimeType" and "relationship").
|
||||
EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error
|
||||
|
||||
// Watermark applies a watermark (behind page content) to a PDF file.
|
||||
Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
|
||||
|
||||
// Stamp applies a stamp (on top of page content) to a PDF file.
|
||||
Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
|
||||
|
||||
// Rotate rotates pages of a PDF file by the given angle (90, 180, 270).
|
||||
// If pages is empty, all pages are rotated.
|
||||
Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
|
||||
}
|
||||
|
||||
// PdfEngineProvider offers an interface to instantiate a [PdfEngine].
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// Package semconv is a copy/paste of utilities that are currently not exposed
|
||||
// in the OpenTelemery Go SDK.
|
||||
// Package semconv contains utilities not yet exposed in the OpenTelemetry Go
|
||||
// SDK. Remove this package once an official API exists.
|
||||
//
|
||||
// This package MUST be removed once an "official" API is provided.
|
||||
//
|
||||
// See: https://github.com/open-telemetry/opentelemetry-go-contrib/issues/4580.
|
||||
// See https://github.com/open-telemetry/opentelemetry-go-contrib/issues/4580.
|
||||
package semconv
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrProcessAlreadyRestarting happens if the [ProcessSupervisor] is trying
|
||||
@@ -53,9 +54,9 @@ type ProcessSupervisor interface {
|
||||
|
||||
// Healthy checks and returns the health status of the managed [Process].
|
||||
//
|
||||
// If the process has not been started or is restarting, it is considered
|
||||
// healthy and true is returned. Otherwise, it returns the health status of
|
||||
// the actual process.
|
||||
// A non-started process is considered healthy (startup is deferred until
|
||||
// the first request). Returns false if the process is currently restarting
|
||||
// or is reported unhealthy by the underlying [Process].
|
||||
Healthy() bool
|
||||
|
||||
// Run executes a provided task while managing the state of the [Process].
|
||||
@@ -70,44 +71,77 @@ type ProcessSupervisor interface {
|
||||
// ReqQueueSize returns the current size of the request queue.
|
||||
ReqQueueSize() int64
|
||||
|
||||
// ActiveTasksCount returns the current number of active tasks.
|
||||
ActiveTasksCount() int64
|
||||
|
||||
// RestartsCount returns the current number of restart.
|
||||
RestartsCount() int64
|
||||
|
||||
// ActiveTasksCount returns the current number of active tasks.
|
||||
ActiveTasksCount() int64
|
||||
}
|
||||
|
||||
// healthCheckCacheTTL caches successful health probe results so kubelet-
|
||||
// style probes (liveness + readiness, every few seconds each) do not
|
||||
// hammer the underlying process with CDP roundtrips on every call.
|
||||
// Tuned to bridge typical probe periods while still catching outages
|
||||
// quickly: a real outage surfaces on the next probe after the TTL
|
||||
// elapses.
|
||||
const healthCheckCacheTTL = 2 * time.Second
|
||||
|
||||
// healthFailureThreshold is the number of consecutive Healthy() failures
|
||||
// the supervisor tolerates before reporting unhealthy. Absorbs single-
|
||||
// probe blips of transient CDP latency (for example a slow
|
||||
// Browser.getVersion roundtrip when several conversion slots are
|
||||
// simultaneously stuck), without delaying detection of a real outage.
|
||||
// The container orchestrator's own failureThreshold stacks on top of
|
||||
// this. See https://github.com/gotenberg/gotenberg/issues/1561.
|
||||
const healthFailureThreshold = 2
|
||||
|
||||
type processSupervisor struct {
|
||||
logger *slog.Logger
|
||||
process Process
|
||||
maxReqLimit int64
|
||||
maxQueueSize int64
|
||||
maxConcurrency int64
|
||||
semaphore chan struct{}
|
||||
firstStart atomic.Bool
|
||||
firstStartOnce sync.Once
|
||||
firstStartErr error
|
||||
reqCounter atomic.Int64
|
||||
reqQueueSize atomic.Int64
|
||||
restartsCounter atomic.Int64
|
||||
isRestarting atomic.Bool
|
||||
activeTasks atomic.Int64
|
||||
restartMutex sync.Mutex
|
||||
logger *slog.Logger
|
||||
process Process
|
||||
maxReqLimit int64
|
||||
maxQueueSize int64
|
||||
maxConcurrency int64
|
||||
semaphore chan struct{}
|
||||
firstStart atomic.Bool
|
||||
// firstStartMu serializes lazy-launch attempts so concurrent callers do
|
||||
// not all spawn Launch() simultaneously. Using a mutex (instead of
|
||||
// sync.Once) lets a failed launch be retried by the next caller, since a
|
||||
// transient failure (such as a cold-start timeout) must not poison the
|
||||
// supervisor for the rest of the container's lifetime. See
|
||||
// https://github.com/gotenberg/gotenberg/issues/1538.
|
||||
firstStartMu sync.Mutex
|
||||
reqCounter atomic.Int64
|
||||
reqQueueSize atomic.Int64
|
||||
restartsCounter atomic.Int64
|
||||
isRestarting atomic.Bool
|
||||
activeTasks atomic.Int64
|
||||
restartMutex sync.Mutex
|
||||
idleShutdownTimeout time.Duration
|
||||
lastActivity atomic.Int64 // unix nano timestamp of last completed task
|
||||
// healthMu serializes Healthy() probes so concurrent callers do not
|
||||
// all issue a CDP roundtrip; the second caller hits the refreshed
|
||||
// cache instead.
|
||||
healthMu sync.Mutex
|
||||
lastHealthyAt atomic.Int64 // unix nano of last successful probe; 0 means never
|
||||
consecutiveHealthFailures atomic.Int64 // reset to 0 on every successful probe
|
||||
idleMu sync.Mutex // protects idleStopChan
|
||||
idleStopChan chan struct{} // signal to stop the idle ticker goroutine
|
||||
}
|
||||
|
||||
// NewProcessSupervisor initializes a new [ProcessSupervisor].
|
||||
func NewProcessSupervisor(logger *slog.Logger, process Process, maxReqLimit, maxQueueSize, maxConcurrency int64) ProcessSupervisor {
|
||||
func NewProcessSupervisor(logger *slog.Logger, process Process, maxReqLimit, maxQueueSize, maxConcurrency int64, idleShutdownTimeout time.Duration) ProcessSupervisor {
|
||||
if maxConcurrency < 1 {
|
||||
maxConcurrency = 1
|
||||
}
|
||||
|
||||
b := &processSupervisor{
|
||||
logger: logger,
|
||||
process: process,
|
||||
semaphore: make(chan struct{}, maxConcurrency),
|
||||
maxReqLimit: maxReqLimit,
|
||||
maxQueueSize: maxQueueSize,
|
||||
maxConcurrency: maxConcurrency,
|
||||
logger: logger,
|
||||
process: process,
|
||||
semaphore: make(chan struct{}, maxConcurrency),
|
||||
maxReqLimit: maxReqLimit,
|
||||
maxQueueSize: maxQueueSize,
|
||||
maxConcurrency: maxConcurrency,
|
||||
idleShutdownTimeout: idleShutdownTimeout,
|
||||
}
|
||||
b.reqCounter.Store(0)
|
||||
b.reqQueueSize.Store(0)
|
||||
@@ -126,6 +160,12 @@ func (s *processSupervisor) Launch() error {
|
||||
}
|
||||
|
||||
s.firstStart.Store(true)
|
||||
|
||||
if s.idleShutdownTimeout > 0 {
|
||||
s.lastActivity.Store(time.Now().UnixNano())
|
||||
s.startIdleTicker()
|
||||
}
|
||||
|
||||
s.logger.DebugContext(context.Background(), "process successfully started")
|
||||
|
||||
return nil
|
||||
@@ -133,6 +173,9 @@ func (s *processSupervisor) Launch() error {
|
||||
|
||||
func (s *processSupervisor) Shutdown() error {
|
||||
s.logger.DebugContext(context.Background(), "shutdown process")
|
||||
|
||||
s.stopIdleTicker()
|
||||
|
||||
err := s.process.Stop(s.logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("shutdown process: %w", err)
|
||||
@@ -148,8 +191,9 @@ func (s *processSupervisor) restart() error {
|
||||
|
||||
err := s.Shutdown()
|
||||
if err != nil {
|
||||
// No big deal? Chances are it's already stopped.
|
||||
s.logger.DebugContext(context.Background(), fmt.Sprintf("stop process before restart: %s", err))
|
||||
// Not necessarily critical — chances are the process is already stopped,
|
||||
// but worth flagging in case it indicates a real issue.
|
||||
s.logger.WarnContext(context.Background(), fmt.Sprintf("stop process before restart: %s", err))
|
||||
}
|
||||
|
||||
err = s.Launch()
|
||||
@@ -166,117 +210,124 @@ func (s *processSupervisor) restart() error {
|
||||
|
||||
func (s *processSupervisor) Healthy() bool {
|
||||
if !s.firstStart.Load() {
|
||||
// A non-started process is always healthy.
|
||||
// A non-started process is considered healthy: Gotenberg defers
|
||||
// process startup until the first request to keep resource usage low.
|
||||
// Reporting unhealthy here would cause container orchestrators to
|
||||
// restart the pod before any request arrives.
|
||||
return true
|
||||
}
|
||||
|
||||
if s.isRestarting.Load() {
|
||||
// A restarting process is always healthy.
|
||||
// A restarting process is not yet healthy. This gives load balancers
|
||||
// honest information so they can avoid routing traffic to this node.
|
||||
return false
|
||||
}
|
||||
|
||||
// Cache hit: a recent probe succeeded. Skip the CDP roundtrip so probe
|
||||
// spam does not pile commands onto a busy websocket.
|
||||
if s.recentlyHealthy() {
|
||||
return true
|
||||
}
|
||||
|
||||
return s.process.Healthy(s.logger)
|
||||
// Serialize probes so concurrent callers do not all roundtrip. The
|
||||
// second caller will see the refreshed cache (or counter) and return
|
||||
// without re-probing.
|
||||
s.healthMu.Lock()
|
||||
defer s.healthMu.Unlock()
|
||||
|
||||
if s.recentlyHealthy() {
|
||||
return true
|
||||
}
|
||||
|
||||
if s.process.Healthy(s.logger) {
|
||||
s.lastHealthyAt.Store(time.Now().UnixNano())
|
||||
s.consecutiveHealthFailures.Store(0)
|
||||
return true
|
||||
}
|
||||
|
||||
if s.consecutiveHealthFailures.Add(1) < healthFailureThreshold {
|
||||
// First failure: tolerate it. Under load, a single blown CDP
|
||||
// timeout is more likely transient pressure than a dead process.
|
||||
// A genuinely dead process will fail the next probe as well and
|
||||
// flip us unhealthy then.
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// recentlyHealthy reports whether a successful probe landed within
|
||||
// [healthCheckCacheTTL]. Negative results are never cached so recovery
|
||||
// from a real outage is observable on the very next probe.
|
||||
func (s *processSupervisor) recentlyHealthy() bool {
|
||||
last := s.lastHealthyAt.Load()
|
||||
if last == 0 {
|
||||
return false
|
||||
}
|
||||
return time.Since(time.Unix(0, last)) < healthCheckCacheTTL
|
||||
}
|
||||
|
||||
func (s *processSupervisor) Run(ctx context.Context, logger *slog.Logger, task func() error) error {
|
||||
// A user reported a potential issue:
|
||||
//
|
||||
// "Although the counting operation is atomic, nothing prevent 2 concurrent
|
||||
// goroutines to retrieve the same 'currentQueueSize' and to compare its
|
||||
// value against the max limit. Then, resulting queue size would be 1 above
|
||||
// the allowed limit."
|
||||
//
|
||||
// However, he was unable to actually trigger this issue, even when sending
|
||||
// a lot of requests.
|
||||
//
|
||||
// For now, the best option is to consider this issue to be unlikely to
|
||||
// happen, and keep the code as it is because it is more readable this way.
|
||||
//
|
||||
// See https://github.com/gotenberg/gotenberg/issues/951.
|
||||
currentQueueSize := s.reqQueueSize.Load()
|
||||
if s.maxQueueSize > 0 && currentQueueSize >= s.maxQueueSize {
|
||||
return ErrMaximumQueueSizeExceeded
|
||||
// Atomically check and increment the queue size to avoid the TOCTOU race
|
||||
// originally reported in https://github.com/gotenberg/gotenberg/issues/951.
|
||||
for {
|
||||
current := s.reqQueueSize.Load()
|
||||
if s.maxQueueSize > 0 && current >= s.maxQueueSize {
|
||||
return ErrMaximumQueueSizeExceeded
|
||||
}
|
||||
if s.reqQueueSize.CompareAndSwap(current, current+1) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
s.reqQueueSize.Add(1)
|
||||
// Decrement when Run() returns, regardless of which path is taken
|
||||
// (context timeout, task completion, error, etc.). This ensures the
|
||||
// request is counted as "in the queue" for the entire duration of Run(),
|
||||
// preventing new requests from entering while one is being processed.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1502.
|
||||
defer s.reqQueueSize.Add(-1)
|
||||
|
||||
for {
|
||||
err := func() error {
|
||||
select {
|
||||
case s.semaphore <- struct{}{}:
|
||||
logger.DebugContext(ctx, "process lock acquired")
|
||||
|
||||
// If a restart drain is in progress, release the slot
|
||||
// immediately so the drain can acquire it instead.
|
||||
if s.isRestarting.Load() {
|
||||
<-s.semaphore
|
||||
return ErrProcessAlreadyRestarting
|
||||
}
|
||||
|
||||
s.reqQueueSize.Add(-1)
|
||||
s.reqCounter.Add(1)
|
||||
s.activeTasks.Add(1)
|
||||
releaseSemaphore := true
|
||||
|
||||
defer func() {
|
||||
s.activeTasks.Add(-1)
|
||||
if releaseSemaphore {
|
||||
logger.DebugContext(ctx, "process lock released")
|
||||
<-s.semaphore
|
||||
}
|
||||
}()
|
||||
|
||||
if !s.firstStart.Load() {
|
||||
s.firstStartOnce.Do(func() {
|
||||
s.firstStartErr = s.runWithDeadline(ctx, func() error {
|
||||
return s.Launch()
|
||||
})
|
||||
})
|
||||
if s.firstStartErr != nil {
|
||||
return fmt.Errorf("process first start: %w", s.firstStartErr)
|
||||
}
|
||||
}
|
||||
|
||||
if !s.Healthy() {
|
||||
s.logger.DebugContext(ctx, "process is unhealthy, cannot handle task, restarting...")
|
||||
err := s.doRestart(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("process restart before task: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
err := s.runWithDeadline(ctx, task)
|
||||
|
||||
if s.maxReqLimit > 0 && s.reqCounter.Load() >= s.maxReqLimit {
|
||||
// Only one goroutine should trigger the restart.
|
||||
if s.restartMutex.TryLock() {
|
||||
s.logger.DebugContext(ctx, "max request limit reached, restarting eagerly...")
|
||||
releaseSemaphore = false
|
||||
|
||||
go func() {
|
||||
restartErr := s.doRestartLocked(context.Background())
|
||||
s.restartMutex.Unlock()
|
||||
if restartErr != nil {
|
||||
s.logger.ErrorContext(context.Background(), fmt.Sprintf("process restart after task: %v", restartErr))
|
||||
}
|
||||
logger.DebugContext(context.Background(), "process lock released")
|
||||
<-s.semaphore
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// Note: no error wrapping because it leaks on Chromium console exceptions output.
|
||||
if err := s.acquireSlot(ctx, logger); err != nil {
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
logger.DebugContext(ctx, "failed to acquire process lock before deadline")
|
||||
s.reqQueueSize.Add(-1)
|
||||
|
||||
return fmt.Errorf("acquire process lock: %w", ctx.Err())
|
||||
}
|
||||
|
||||
s.reqCounter.Add(1)
|
||||
s.activeTasks.Add(1)
|
||||
semaphoreOwned := true
|
||||
|
||||
defer func() {
|
||||
s.activeTasks.Add(-1)
|
||||
if s.idleShutdownTimeout > 0 {
|
||||
s.lastActivity.Store(time.Now().UnixNano())
|
||||
}
|
||||
if semaphoreOwned {
|
||||
logger.DebugContext(ctx, "process lock released")
|
||||
<-s.semaphore
|
||||
}
|
||||
}()
|
||||
|
||||
if err := s.ensureStarted(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.ensureHealthy(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := s.runWithDeadline(ctx, task)
|
||||
|
||||
if s.maybeRestartAfterTask(logger) {
|
||||
semaphoreOwned = false
|
||||
}
|
||||
|
||||
// Note: no error wrapping because it leaks on Chromium console exceptions output.
|
||||
return err
|
||||
}()
|
||||
|
||||
if errors.Is(err, ErrProcessAlreadyRestarting) {
|
||||
logger.DebugContext(ctx, "process is already restarting, trying to acquire process lock again...")
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -285,6 +336,178 @@ func (s *processSupervisor) Run(ctx context.Context, logger *slog.Logger, task f
|
||||
}
|
||||
}
|
||||
|
||||
// startIdleTicker starts a background goroutine that periodically checks
|
||||
// whether the process has been idle long enough to shut down.
|
||||
func (s *processSupervisor) startIdleTicker() {
|
||||
stopChan := make(chan struct{})
|
||||
|
||||
s.idleMu.Lock()
|
||||
s.idleStopChan = stopChan
|
||||
s.idleMu.Unlock()
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(s.idleShutdownTimeout)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.maybeIdleShutdown()
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// stopIdleTicker signals the idle ticker goroutine to exit, if one is running.
|
||||
func (s *processSupervisor) stopIdleTicker() {
|
||||
s.idleMu.Lock()
|
||||
defer s.idleMu.Unlock()
|
||||
|
||||
if s.idleStopChan != nil {
|
||||
close(s.idleStopChan)
|
||||
s.idleStopChan = nil
|
||||
}
|
||||
}
|
||||
|
||||
// maybeIdleShutdown stops the process if it has been idle for longer than
|
||||
// the configured timeout. It is safe to call concurrently with Run and
|
||||
// restart.
|
||||
func (s *processSupervisor) maybeIdleShutdown() {
|
||||
if !s.firstStart.Load() || s.isRestarting.Load() {
|
||||
return
|
||||
}
|
||||
|
||||
if s.activeTasks.Load() > 0 || s.reqQueueSize.Load() > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
lastNano := s.lastActivity.Load()
|
||||
if lastNano == 0 || time.Since(time.Unix(0, lastNano)) < s.idleShutdownTimeout {
|
||||
return
|
||||
}
|
||||
|
||||
if !s.restartMutex.TryLock() {
|
||||
return
|
||||
}
|
||||
defer s.restartMutex.Unlock()
|
||||
|
||||
// Double-check after acquiring the lock.
|
||||
if s.activeTasks.Load() > 0 || s.reqQueueSize.Load() > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.DebugContext(context.Background(), "idle shutdown timeout reached, stopping process")
|
||||
|
||||
// Stop the ticker — it will be restarted on the next Launch().
|
||||
s.stopIdleTicker()
|
||||
|
||||
err := s.process.Stop(s.logger)
|
||||
if err != nil {
|
||||
s.logger.WarnContext(context.Background(), fmt.Sprintf("idle shutdown: %s", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Reset state so ensureStarted() re-launches on next request.
|
||||
s.firstStart.Store(false)
|
||||
s.reqCounter.Store(0)
|
||||
|
||||
s.logger.DebugContext(context.Background(), "process stopped due to idle timeout")
|
||||
}
|
||||
|
||||
// acquireSlot attempts to acquire a semaphore slot, yielding it back if a
|
||||
// restart drain is in progress.
|
||||
func (s *processSupervisor) acquireSlot(ctx context.Context, logger *slog.Logger) error {
|
||||
select {
|
||||
case s.semaphore <- struct{}{}:
|
||||
// If a restart drain is in progress, release the slot
|
||||
// immediately so the drain can acquire it instead.
|
||||
if s.isRestarting.Load() {
|
||||
<-s.semaphore
|
||||
return ErrProcessAlreadyRestarting
|
||||
}
|
||||
|
||||
logger.DebugContext(ctx, "process lock acquired")
|
||||
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
logger.DebugContext(ctx, "failed to acquire process lock before deadline")
|
||||
|
||||
return fmt.Errorf("acquire process lock: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
// ensureStarted performs a lazy launch of the process on its first use.
|
||||
// Concurrent callers serialize on firstStartMu; once the launch succeeds,
|
||||
// subsequent calls short-circuit on the firstStart flag. A failed launch
|
||||
// leaves firstStart unset, so the next caller retries the launch.
|
||||
func (s *processSupervisor) ensureStarted(ctx context.Context) error {
|
||||
if s.firstStart.Load() {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.firstStartMu.Lock()
|
||||
defer s.firstStartMu.Unlock()
|
||||
|
||||
if s.firstStart.Load() {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := s.runWithDeadline(ctx, func() error {
|
||||
return s.Launch()
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("process first start: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureHealthy checks the underlying process health and triggers a
|
||||
// synchronous restart if the process is unhealthy. Skips the check if a
|
||||
// restart is already in progress.
|
||||
func (s *processSupervisor) ensureHealthy(ctx context.Context) error {
|
||||
if s.isRestarting.Load() || s.process.Healthy(s.logger) {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.logger.DebugContext(context.Background(), "process is unhealthy, cannot handle task, restarting...")
|
||||
|
||||
if err := s.doRestart(ctx); err != nil {
|
||||
return fmt.Errorf("process restart before task: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// maybeRestartAfterTask checks if the maximum request limit has been reached
|
||||
// and, if so, triggers an asynchronous restart. If a restart is initiated, it
|
||||
// takes ownership 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 {
|
||||
if s.maxReqLimit <= 0 || s.reqCounter.Load() < s.maxReqLimit {
|
||||
return false
|
||||
}
|
||||
|
||||
if !s.restartMutex.TryLock() {
|
||||
return false
|
||||
}
|
||||
|
||||
s.logger.DebugContext(context.Background(), "max request limit reached, restarting eagerly...")
|
||||
|
||||
go func() {
|
||||
restartErr := s.doRestartLocked(context.Background())
|
||||
s.restartMutex.Unlock()
|
||||
if restartErr != nil {
|
||||
s.logger.ErrorContext(context.Background(), fmt.Sprintf("process restart after task: %v", restartErr))
|
||||
}
|
||||
logger.DebugContext(context.Background(), "process lock released")
|
||||
<-s.semaphore
|
||||
}()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// doRestart coordinates a process restart, draining all active concurrent
|
||||
// tasks before stopping and restarting the process.
|
||||
func (s *processSupervisor) doRestart(ctx context.Context) error {
|
||||
@@ -346,14 +569,14 @@ func (s *processSupervisor) ReqQueueSize() int64 {
|
||||
return s.reqQueueSize.Load()
|
||||
}
|
||||
|
||||
func (s *processSupervisor) ActiveTasksCount() int64 {
|
||||
return s.activeTasks.Load()
|
||||
}
|
||||
|
||||
func (s *processSupervisor) RestartsCount() int64 {
|
||||
return s.restartsCounter.Load()
|
||||
}
|
||||
|
||||
func (s *processSupervisor) ActiveTasksCount() int64 {
|
||||
return s.activeTasks.Load()
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ ProcessSupervisor = (*processSupervisor)(nil)
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -38,7 +37,7 @@ func TestProcessSupervisor_Launch(t *testing.T) {
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
process := &ProcessMock{
|
||||
StartMock: func(logger *slog.Logger) error {
|
||||
@@ -46,7 +45,7 @@ func TestProcessSupervisor_Launch(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
|
||||
if tc.firstStartSet {
|
||||
ps.firstStart.Store(true)
|
||||
}
|
||||
@@ -86,7 +85,7 @@ func TestProcessSupervisor_Shutdown(t *testing.T) {
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
process := &ProcessMock{
|
||||
StopMock: func(logger *slog.Logger) error {
|
||||
@@ -94,7 +93,7 @@ func TestProcessSupervisor_Shutdown(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1)
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0)
|
||||
err := ps.Shutdown()
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
@@ -135,7 +134,7 @@ func TestProcessSupervisor_restart(t *testing.T) {
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
process := &ProcessMock{
|
||||
StartMock: func(logger *slog.Logger) error {
|
||||
@@ -146,7 +145,7 @@ func TestProcessSupervisor_restart(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
|
||||
|
||||
err := ps.restart()
|
||||
|
||||
@@ -170,15 +169,15 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
|
||||
expectHealthy bool
|
||||
}{
|
||||
{
|
||||
scenario: "non-started process is always healthy",
|
||||
scenario: "non-started process is healthy",
|
||||
initiallyStarted: false,
|
||||
expectHealthy: true,
|
||||
},
|
||||
{
|
||||
scenario: "restarting process is always healthy",
|
||||
scenario: "restarting process is not healthy",
|
||||
initiallyStarted: true,
|
||||
initiallyRestarting: true,
|
||||
expectHealthy: true,
|
||||
expectHealthy: false,
|
||||
},
|
||||
{
|
||||
scenario: "process reports as healthy",
|
||||
@@ -187,14 +186,14 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
|
||||
expectHealthy: true,
|
||||
},
|
||||
{
|
||||
scenario: "process reports as unhealthy",
|
||||
scenario: "single probe failure is tolerated",
|
||||
initiallyStarted: true,
|
||||
processHealthy: false,
|
||||
expectHealthy: false,
|
||||
expectHealthy: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
process := &ProcessMock{
|
||||
HealthyMock: func(logger *slog.Logger) bool {
|
||||
@@ -202,7 +201,7 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
|
||||
if tc.initiallyStarted {
|
||||
ps.firstStart.Store(true)
|
||||
}
|
||||
@@ -219,6 +218,109 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessSupervisor_Healthy_ConsecutiveFailures verifies that only
|
||||
// the second consecutive process-level failure flips the supervisor to
|
||||
// unhealthy, and that a single success in between resets the counter.
|
||||
func TestProcessSupervisor_Healthy_ConsecutiveFailures(t *testing.T) {
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
var processHealthy atomic.Bool
|
||||
process := &ProcessMock{
|
||||
HealthyMock: func(_ *slog.Logger) bool { return processHealthy.Load() },
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
|
||||
ps.firstStart.Store(true)
|
||||
|
||||
processHealthy.Store(false)
|
||||
if !ps.Healthy() {
|
||||
t.Fatal("first failure should be tolerated and report healthy")
|
||||
}
|
||||
if ps.Healthy() {
|
||||
t.Fatal("second consecutive failure should report unhealthy")
|
||||
}
|
||||
|
||||
processHealthy.Store(true)
|
||||
if !ps.Healthy() {
|
||||
t.Fatal("recovery should report healthy immediately")
|
||||
}
|
||||
|
||||
processHealthy.Store(false)
|
||||
// Cache hit from the previous success absorbs the first new failure;
|
||||
// invalidate it so we exercise the counter again.
|
||||
ps.lastHealthyAt.Store(0)
|
||||
if !ps.Healthy() {
|
||||
t.Fatal("post-recovery first failure should be tolerated again")
|
||||
}
|
||||
if ps.Healthy() {
|
||||
t.Fatal("post-recovery second consecutive failure should report unhealthy")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessSupervisor_Healthy_CachesPositiveResult verifies that a
|
||||
// successful probe is cached for [healthCheckCacheTTL] so subsequent
|
||||
// supervisor.Healthy() calls do not re-issue the underlying process
|
||||
// check.
|
||||
func TestProcessSupervisor_Healthy_CachesPositiveResult(t *testing.T) {
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
var calls atomic.Int64
|
||||
process := &ProcessMock{
|
||||
HealthyMock: func(_ *slog.Logger) bool {
|
||||
calls.Add(1)
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
|
||||
ps.firstStart.Store(true)
|
||||
|
||||
for range 5 {
|
||||
if !ps.Healthy() {
|
||||
t.Fatal("expected healthy")
|
||||
}
|
||||
}
|
||||
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("process.Healthy called %d times, want exactly 1 (cache should absorb the other 4)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessSupervisor_Healthy_DoesNotCacheNegativeResult verifies that
|
||||
// a probe failure is not cached: the next Healthy() call must re-issue
|
||||
// the underlying process check so a recovered process surfaces on the
|
||||
// very next probe.
|
||||
func TestProcessSupervisor_Healthy_DoesNotCacheNegativeResult(t *testing.T) {
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
var calls atomic.Int64
|
||||
var processHealthy atomic.Bool
|
||||
process := &ProcessMock{
|
||||
HealthyMock: func(_ *slog.Logger) bool {
|
||||
calls.Add(1)
|
||||
return processHealthy.Load()
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
|
||||
ps.firstStart.Store(true)
|
||||
|
||||
processHealthy.Store(false)
|
||||
_ = ps.Healthy()
|
||||
_ = ps.Healthy()
|
||||
if got := calls.Load(); got != 2 {
|
||||
t.Fatalf("after two failing probes, process.Healthy called %d times, want 2 (negative results must not be cached)", got)
|
||||
}
|
||||
|
||||
processHealthy.Store(true)
|
||||
if !ps.Healthy() {
|
||||
t.Fatal("expected healthy on recovery")
|
||||
}
|
||||
if got := calls.Load(); got != 3 {
|
||||
t.Fatalf("after recovery, process.Healthy called %d times, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_Run(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
@@ -365,7 +467,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
var startCalls, healthyCalls, stopCalls atomic.Int64
|
||||
startCalls.Store(0)
|
||||
@@ -387,7 +489,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit, tc.maxQueueSize, 1).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit, tc.maxQueueSize, 1, 0).(*processSupervisor)
|
||||
if tc.initiallyStarted {
|
||||
ps.firstStart.Store(true)
|
||||
}
|
||||
@@ -471,7 +573,7 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
ps := NewProcessSupervisor(slog.New(slog.NewJSONHandler(os.Stdout, nil)), new(ProcessMock), 0, 0, 1).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(slog.New(slog.DiscardHandler), new(ProcessMock), 0, 0, 1, 0).(*processSupervisor)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
if tc.ctxDone {
|
||||
@@ -496,7 +598,7 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
process := &ProcessMock{
|
||||
StartMock: func(logger *slog.Logger) error {
|
||||
return nil
|
||||
@@ -505,7 +607,7 @@ func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
|
||||
return true
|
||||
},
|
||||
}
|
||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
|
||||
|
||||
// Simulating a lock.
|
||||
ps.semaphore <- struct{}{}
|
||||
@@ -552,6 +654,111 @@ func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_QueueSizeCAS(t *testing.T) {
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
process := &ProcessMock{
|
||||
StartMock: func(logger *slog.Logger) error {
|
||||
return nil
|
||||
},
|
||||
HealthyMock: func(logger *slog.Logger) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
maxQueueSize := int64(50)
|
||||
// maxConcurrency=1 so all goroutines block on the semaphore, exercising queue logic.
|
||||
ps := NewProcessSupervisor(logger, process, 0, maxQueueSize, 1, 0).(*processSupervisor)
|
||||
|
||||
// Simulating a lock so that all goroutines queue up.
|
||||
ps.semaphore <- struct{}{}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
goroutines := 100
|
||||
var wg sync.WaitGroup
|
||||
var exceeded atomic.Int64
|
||||
|
||||
for range goroutines {
|
||||
wg.Go(func() {
|
||||
err := ps.Run(ctx, logger, func() error {
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrMaximumQueueSizeExceeded) {
|
||||
exceeded.Add(1)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Wait a bit for goroutines to queue up.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
currentQueue := ps.ReqQueueSize()
|
||||
if currentQueue > maxQueueSize {
|
||||
t.Fatalf("queue size %d exceeded max %d", currentQueue, maxQueueSize)
|
||||
}
|
||||
|
||||
cancel()
|
||||
wg.Wait()
|
||||
|
||||
if exceeded.Load() < int64(goroutines)-maxQueueSize {
|
||||
t.Errorf("expected at least %d rejections, got %d", goroutines-int(maxQueueSize), exceeded.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_QueueSizeIncludesActiveTasks(t *testing.T) {
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
process := &ProcessMock{
|
||||
StartMock: func(logger *slog.Logger) error {
|
||||
return nil
|
||||
},
|
||||
HealthyMock: func(logger *slog.Logger) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
// maxQueueSize=1, maxConcurrency=1: only one request at a time.
|
||||
ps := NewProcessSupervisor(logger, process, 0, 1, 1, 0)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
taskStarted := make(chan struct{})
|
||||
taskDone := make(chan struct{})
|
||||
|
||||
// Start a long-running task that holds the slot.
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
err := ps.Run(ctx, logger, func() error {
|
||||
close(taskStarted)
|
||||
<-taskDone
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("first task: unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for the first task to be running.
|
||||
<-taskStarted
|
||||
|
||||
// A second request should be rejected immediately because the queue
|
||||
// slot is still held by the active task.
|
||||
err := ps.Run(ctx, logger, func() error {
|
||||
return nil
|
||||
})
|
||||
if !errors.Is(err, ErrMaximumQueueSizeExceeded) {
|
||||
t.Fatalf("expected ErrMaximumQueueSizeExceeded but got: %v", err)
|
||||
}
|
||||
|
||||
close(taskDone)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_RestartsCount(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
@@ -593,7 +800,7 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
process := &ProcessMock{
|
||||
StartMock: func(logger *slog.Logger) error {
|
||||
@@ -604,7 +811,7 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
|
||||
ps.restartsCounter.Store(tc.initialRestartsCount)
|
||||
|
||||
for i := 0; i < tc.restartAttempts; i++ {
|
||||
@@ -620,7 +827,7 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
var startCalls atomic.Int64
|
||||
process := &ProcessMock{
|
||||
@@ -637,7 +844,7 @@ func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
|
||||
}
|
||||
|
||||
maxConcurrency := int64(3)
|
||||
ps := NewProcessSupervisor(logger, process, 0, 0, maxConcurrency).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, process, 0, 0, maxConcurrency, 0).(*processSupervisor)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -684,7 +891,7 @@ func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
process := &ProcessMock{
|
||||
StartMock: func(logger *slog.Logger) error {
|
||||
@@ -699,7 +906,7 @@ func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) {
|
||||
}
|
||||
|
||||
maxConcurrency := int64(3)
|
||||
ps := NewProcessSupervisor(logger, process, 3, 0, maxConcurrency).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, process, 3, 0, maxConcurrency, 0).(*processSupervisor)
|
||||
ps.firstStart.Store(true)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -737,3 +944,150 @@ func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) {
|
||||
t.Fatalf("expected 1 restart, got %d", ps.RestartsCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_IdleShutdown(t *testing.T) {
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
var stopCalls atomic.Int64
|
||||
process := &ProcessMock{
|
||||
StartMock: func(logger *slog.Logger) error {
|
||||
return nil
|
||||
},
|
||||
StopMock: func(logger *slog.Logger) error {
|
||||
stopCalls.Add(1)
|
||||
return nil
|
||||
},
|
||||
HealthyMock: func(logger *slog.Logger) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
idleTimeout := 50 * time.Millisecond
|
||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, idleTimeout).(*processSupervisor)
|
||||
|
||||
ctx := context.Background()
|
||||
err := ps.Run(ctx, logger, func() error {
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Wait for idle shutdown to fire.
|
||||
deadline := time.After(2 * time.Second)
|
||||
for ps.firstStart.Load() {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("timed out waiting for idle shutdown")
|
||||
default:
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
if stopCalls.Load() < 1 {
|
||||
t.Fatal("expected process to be stopped via idle shutdown")
|
||||
}
|
||||
|
||||
// Verify re-launch on next request.
|
||||
err = ps.Run(ctx, logger, func() error {
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error on re-launch: %v", err)
|
||||
}
|
||||
|
||||
if !ps.firstStart.Load() {
|
||||
t.Fatal("expected process to be re-launched after idle shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_RetryAfterFailedFirstStart(t *testing.T) {
|
||||
// Regression test for https://github.com/gotenberg/gotenberg/issues/1538:
|
||||
// a failed first launch must not poison the supervisor; the next request
|
||||
// must retry Launch() instead of returning the cached error forever.
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
var startCalls atomic.Int64
|
||||
process := &ProcessMock{
|
||||
StartMock: func(logger *slog.Logger) error {
|
||||
if startCalls.Add(1) == 1 {
|
||||
return errors.New("first start failed")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
StopMock: func(logger *slog.Logger) error {
|
||||
return nil
|
||||
},
|
||||
HealthyMock: func(logger *slog.Logger) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := ps.Run(ctx, logger, func() error { return nil })
|
||||
if err == nil {
|
||||
t.Fatal("expected first Run to fail because Launch failed")
|
||||
}
|
||||
if ps.firstStart.Load() {
|
||||
t.Fatal("firstStart must remain false after a failed Launch")
|
||||
}
|
||||
|
||||
err = ps.Run(ctx, logger, func() error { return nil })
|
||||
if err != nil {
|
||||
t.Fatalf("expected second Run to succeed after the supervisor retries Launch, got: %v", err)
|
||||
}
|
||||
if !ps.firstStart.Load() {
|
||||
t.Fatal("expected firstStart to be set after the second Launch succeeds")
|
||||
}
|
||||
if got := startCalls.Load(); got != 2 {
|
||||
t.Fatalf("expected exactly 2 Start calls, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_IdleShutdownSkippedWhenActive(t *testing.T) {
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
var stopCalls atomic.Int64
|
||||
taskRunning := make(chan struct{})
|
||||
taskDone := make(chan struct{})
|
||||
|
||||
process := &ProcessMock{
|
||||
StartMock: func(logger *slog.Logger) error {
|
||||
return nil
|
||||
},
|
||||
StopMock: func(logger *slog.Logger) error {
|
||||
stopCalls.Add(1)
|
||||
return nil
|
||||
},
|
||||
HealthyMock: func(logger *slog.Logger) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
idleTimeout := 50 * time.Millisecond
|
||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, idleTimeout)
|
||||
|
||||
ctx := context.Background()
|
||||
go func() {
|
||||
_ = ps.Run(ctx, logger, func() error {
|
||||
close(taskRunning)
|
||||
<-taskDone
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
<-taskRunning
|
||||
|
||||
// Wait longer than the idle timeout while a task is active.
|
||||
time.Sleep(idleTimeout * 3)
|
||||
|
||||
if stopCalls.Load() > 0 {
|
||||
t.Fatal("idle shutdown should not fire while a task is active")
|
||||
}
|
||||
|
||||
close(taskDone)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"go.uber.org/multierr"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg/internal/log"
|
||||
internalotel "github.com/gotenberg/gotenberg/v8/pkg/gotenberg/internal/otel"
|
||||
@@ -55,13 +54,13 @@ func (cfg TelemetryConfig) Validate() error {
|
||||
var err error
|
||||
|
||||
if cfg.ServiceName == "" {
|
||||
err = multierr.Append(err,
|
||||
err = errors.Join(err,
|
||||
errors.New("service name must not be empty"),
|
||||
)
|
||||
}
|
||||
|
||||
if cfg.ServiceVersion == "" {
|
||||
err = multierr.Append(err,
|
||||
err = errors.Join(err,
|
||||
errors.New("service version must not be empty"),
|
||||
)
|
||||
}
|
||||
@@ -70,7 +69,7 @@ func (cfg TelemetryConfig) Validate() error {
|
||||
case ErrorLoggingLevel, WarnLoggingLevel, InfoLoggingLevel, DebugLoggingLevel:
|
||||
break
|
||||
default:
|
||||
err = multierr.Append(
|
||||
err = errors.Join(
|
||||
err,
|
||||
fmt.Errorf("log level must be either %s, %s, %s or %s", ErrorLoggingLevel, WarnLoggingLevel, InfoLoggingLevel, DebugLoggingLevel),
|
||||
)
|
||||
@@ -80,7 +79,7 @@ func (cfg TelemetryConfig) Validate() error {
|
||||
case AutoLoggingFormat, JsonLoggingFormat, TextLoggingFormat:
|
||||
break
|
||||
default:
|
||||
err = multierr.Append(
|
||||
err = errors.Join(
|
||||
err,
|
||||
fmt.Errorf("standard log format must be either %s, %s or %s", AutoLoggingFormat, JsonLoggingFormat, TextLoggingFormat),
|
||||
)
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -16,7 +15,6 @@ import (
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/labstack/echo/v4"
|
||||
flag "github.com/spf13/pflag"
|
||||
"go.uber.org/multierr"
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
@@ -28,7 +26,7 @@ func init() {
|
||||
}
|
||||
|
||||
// Api is a module that provides an HTTP server. Other modules may add routes,
|
||||
// middlewares, or health checks.
|
||||
// middlewares or health checks.
|
||||
type Api struct {
|
||||
port int
|
||||
bindIp string
|
||||
@@ -38,14 +36,14 @@ type Api struct {
|
||||
bodyLimit int64
|
||||
timeout time.Duration
|
||||
rootPath string
|
||||
correlationIdHeader string
|
||||
basicAuthUsername string
|
||||
basicAuthPassword string
|
||||
correlationIdHeader string
|
||||
downloadFromCfg downloadFromConfig
|
||||
disableHealthCheckRouteTelemetry bool
|
||||
disableRootRouteTelemetry bool
|
||||
disableVersionRouteTelemetry bool
|
||||
disableDebugRouteTelemetry bool
|
||||
disableVersionRouteTelemetry bool
|
||||
enableDebugRoute bool
|
||||
|
||||
routes []Route
|
||||
@@ -59,10 +57,12 @@ type Api struct {
|
||||
}
|
||||
|
||||
type downloadFromConfig struct {
|
||||
allowList *regexp2.Regexp
|
||||
denyList *regexp2.Regexp
|
||||
maxRetry int
|
||||
disable bool
|
||||
allowList []*regexp2.Regexp
|
||||
denyList []*regexp2.Regexp
|
||||
denyPrivateIPs bool
|
||||
denyPublicIPs bool
|
||||
maxRetry int
|
||||
disable bool
|
||||
}
|
||||
|
||||
// Router is a module interface that adds routes to the [Api].
|
||||
@@ -84,7 +84,8 @@ type Route struct {
|
||||
// Optional.
|
||||
IsMultipart bool
|
||||
|
||||
// DisableTelemetry disables the telemetry and logging for this route.
|
||||
// DisableTelemetry disables telemetry (logging, tracing, metrics) for
|
||||
// this route.
|
||||
// Optional.
|
||||
DisableTelemetry bool
|
||||
|
||||
@@ -122,6 +123,29 @@ const (
|
||||
|
||||
// Middleware is a middleware that can be added to the [Api]'s middlewares
|
||||
// chain.
|
||||
//
|
||||
// middleware := Middleware{
|
||||
// Handler: func() echo.MiddlewareFunc {
|
||||
// return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
// return func(c echo.Context) error {
|
||||
// rootPath := c.Get("rootPath").(string)
|
||||
// healthURI := fmt.Sprintf("%shealth", rootPath)
|
||||
//
|
||||
// // Skip the middleware if health check URI.
|
||||
// if c.Request().RequestURI == healthURI {
|
||||
// // Call the next middleware in the chain.
|
||||
// return next(c)
|
||||
// }
|
||||
//
|
||||
// // Your middleware process.
|
||||
// // ...
|
||||
//
|
||||
// // Call the next middleware in the chain.
|
||||
// return next(c)
|
||||
// }
|
||||
// }
|
||||
// }(),
|
||||
// }
|
||||
type Middleware struct {
|
||||
// Stack tells in which stack the middleware should be located.
|
||||
// Default to [DefaultStack].
|
||||
@@ -171,18 +195,31 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
||||
fs.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests")
|
||||
fs.String("api-body-limit", "", "Set the body limit for multipart/form-data requests - it accepts values like 5MB, 1GB, etc")
|
||||
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.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.String("api-correlation-id-header", "X-Correlation-ID", "Set the header name to use to set the correlation id in the logs")
|
||||
fs.String("api-download-from-allow-list", "", "Set the allowed URLs for the download from feature using a regular expression")
|
||||
fs.String("api-download-from-deny-list", "", "Set the denied URLs for the download from feature using a regular expression")
|
||||
fs.StringSlice("api-download-from-allow-list", []string{}, "Set the allowed 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-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.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature")
|
||||
fs.Bool("api-disable-download-from", false, "Disable the download from feature")
|
||||
fs.Bool("api-disable-health-check-route-telemetry", false, "Disable the health check route telemetry")
|
||||
fs.Bool("api-disable-root-route-telemetry", false, "Disable the root route telemetry")
|
||||
fs.Bool("api-disable-version-route-telemetry", false, "Disable the version route telemetry")
|
||||
fs.Bool("api-disable-debug-route-telemetry", false, "Disable the debug route telemetry")
|
||||
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-debug-route-telemetry", true, "Disable telemetry for the debug route")
|
||||
fs.Bool("api-disable-version-route-telemetry", true, "Disable telemetry for the version route")
|
||||
fs.Bool("api-enable-debug-route", false, "Enable the debug route")
|
||||
|
||||
// Deprecated flags.
|
||||
fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
|
||||
fs.Bool("api-disable-health-check-logging", false, "Disable health check logging")
|
||||
|
||||
err := errors.Join(
|
||||
fs.MarkDeprecated("api-trace-header", "use --api-correlation-id-header instead"),
|
||||
fs.MarkDeprecated("api-disable-health-check-logging", "use --api-disable-health-check-route-telemetry instead"),
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return fs
|
||||
}(),
|
||||
New: func() gotenberg.Module { return new(Api) },
|
||||
@@ -200,17 +237,19 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
||||
a.timeout = flags.MustDuration("api-timeout")
|
||||
a.bodyLimit = flags.MustHumanReadableBytes("api-body-limit")
|
||||
a.rootPath = flags.MustString("api-root-path")
|
||||
a.correlationIdHeader = flags.MustString("api-correlation-id-header")
|
||||
a.correlationIdHeader = flags.MustDeprecatedString("api-trace-header", "api-correlation-id-header")
|
||||
a.downloadFromCfg = downloadFromConfig{
|
||||
allowList: flags.MustRegexp("api-download-from-allow-list"),
|
||||
denyList: flags.MustRegexp("api-download-from-deny-list"),
|
||||
maxRetry: flags.MustInt("api-download-from-max-retry"),
|
||||
disable: flags.MustBool("api-disable-download-from"),
|
||||
allowList: flags.MustRegexpSlice("api-download-from-allow-list"),
|
||||
denyList: flags.MustRegexpSlice("api-download-from-deny-list"),
|
||||
denyPrivateIPs: flags.MustBool("api-download-from-deny-private-ips"),
|
||||
denyPublicIPs: flags.MustBool("api-download-from-deny-public-ips"),
|
||||
maxRetry: flags.MustInt("api-download-from-max-retry"),
|
||||
disable: flags.MustBool("api-disable-download-from"),
|
||||
}
|
||||
a.disableHealthCheckRouteTelemetry = flags.MustBool("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.disableVersionRouteTelemetry = flags.MustBool("api-disable-version-route-telemetry")
|
||||
a.disableDebugRouteTelemetry = flags.MustBool("api-disable-debug-route-telemetry")
|
||||
a.disableVersionRouteTelemetry = flags.MustBool("api-disable-version-route-telemetry")
|
||||
a.enableDebugRoute = flags.MustBool("api-enable-debug-route")
|
||||
|
||||
// Port from env?
|
||||
@@ -329,33 +368,39 @@ func (a *Api) Validate() error {
|
||||
var err error
|
||||
|
||||
if a.port < 1 || a.port > 65535 {
|
||||
err = multierr.Append(err,
|
||||
err = errors.Join(err,
|
||||
errors.New("port must be more than 1 and less than 65535"),
|
||||
)
|
||||
}
|
||||
|
||||
if a.bindIp != "" && net.ParseIP(a.bindIp) == nil {
|
||||
err = multierr.Append(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.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") {
|
||||
err = multierr.Append(err,
|
||||
err = errors.Join(err,
|
||||
errors.New("both TLS certificate and key files must be set"),
|
||||
)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(a.rootPath, "/") {
|
||||
err = multierr.Append(err,
|
||||
err = errors.Join(err,
|
||||
errors.New("root path must start with /"),
|
||||
)
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(a.rootPath, "/") {
|
||||
err = multierr.Append(err,
|
||||
err = errors.Join(err,
|
||||
errors.New("root path must end with /"),
|
||||
)
|
||||
}
|
||||
|
||||
if len(strings.TrimSpace(a.correlationIdHeader)) == 0 {
|
||||
err = errors.Join(err,
|
||||
errors.New("trace header must not be empty"),
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -422,40 +467,29 @@ func (a *Api) Start() error {
|
||||
disableTelemetryForPaths = append(disableTelemetryForPaths, strings.TrimPrefix(route.Path, "/"))
|
||||
}
|
||||
}
|
||||
// Check if the user wishes to disable telemetry for system routes.
|
||||
// Note: root path will be used as prefix in the underlying middlewares.
|
||||
|
||||
// Check if the user wishes to disable telemetry for specific routes.
|
||||
if a.disableHealthCheckRouteTelemetry {
|
||||
disableTelemetryForPaths = append(disableTelemetryForPaths, "health")
|
||||
}
|
||||
|
||||
if a.disableRootRouteTelemetry {
|
||||
disableTelemetryForPaths = append(disableTelemetryForPaths, "")
|
||||
}
|
||||
|
||||
if a.disableDebugRouteTelemetry {
|
||||
disableTelemetryForPaths = append(disableTelemetryForPaths, "debug")
|
||||
}
|
||||
if a.disableVersionRouteTelemetry {
|
||||
disableTelemetryForPaths = append(disableTelemetryForPaths, "version")
|
||||
}
|
||||
|
||||
if a.disableDebugRouteTelemetry {
|
||||
disableTelemetryForPaths = append(disableTelemetryForPaths, "debug")
|
||||
}
|
||||
|
||||
// Always disable telemetry for favicon.
|
||||
disableTelemetryForPaths = append(disableTelemetryForPaths,
|
||||
"favicon.ico",
|
||||
)
|
||||
serverName := fmt.Sprintf("%s:%d", a.bindIp, a.port)
|
||||
|
||||
// Add the API middlewares.
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get hostname: %w", err)
|
||||
}
|
||||
|
||||
a.srv.Pre(
|
||||
latencyMiddleware(),
|
||||
rootPathMiddleware(a.rootPath),
|
||||
outputFilenameMiddleware(),
|
||||
telemetryMiddleware(a.logger, hostname, a.correlationIdHeader, disableTelemetryForPaths),
|
||||
telemetryMiddleware(a.logger, serverName, a.correlationIdHeader, disableTelemetryForPaths),
|
||||
)
|
||||
|
||||
// Add the modules' middlewares in their respective stacks.
|
||||
@@ -527,7 +561,9 @@ func (a *Api) Start() error {
|
||||
)
|
||||
|
||||
// Let's not forget the health check routes...
|
||||
checks := append(a.healthChecks, health.WithTimeout(a.timeout))
|
||||
checks := make([]health.CheckerOption, len(a.healthChecks), len(a.healthChecks)+1)
|
||||
copy(checks, a.healthChecks)
|
||||
checks = append(checks, health.WithTimeout(a.timeout))
|
||||
checker := health.NewChecker(checks...)
|
||||
healthCheckHandler := health.NewHandler(checker)
|
||||
|
||||
@@ -575,7 +611,7 @@ func (a *Api) Start() error {
|
||||
eg.Go(f)
|
||||
}
|
||||
|
||||
err = eg.Wait()
|
||||
err := eg.Wait()
|
||||
if err != nil {
|
||||
return fmt.Errorf("waiting for modules readiness: %w", err)
|
||||
}
|
||||
@@ -593,7 +629,6 @@ func (a *Api) Start() error {
|
||||
}
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
a.logger.ErrorContext(context.Background(), err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -21,13 +21,14 @@ import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mholt/archives"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
semconvutil "github.com/gotenberg/gotenberg/v8/pkg/gotenberg/semconv"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -42,12 +43,13 @@ var (
|
||||
|
||||
// Context is the request context for a "multipart/form-data" request.
|
||||
type Context struct {
|
||||
dirPath string
|
||||
values map[string][]string
|
||||
files map[string]string
|
||||
filesByField map[string][]string
|
||||
outputPaths []string
|
||||
cancelled bool
|
||||
dirPath string
|
||||
values map[string][]string
|
||||
files map[string]string
|
||||
filesByField map[string][]string
|
||||
diskToOriginal map[string]string
|
||||
outputPaths []string
|
||||
cancelled bool
|
||||
|
||||
logger *slog.Logger
|
||||
echoCtx echo.Context
|
||||
@@ -85,8 +87,14 @@ type downloadFrom struct {
|
||||
// ExtraHttpHeaders are the HTTP headers to send alongside.
|
||||
ExtraHttpHeaders map[string]string `json:"extraHttpHeaders"`
|
||||
|
||||
// Download as an attachment file.
|
||||
Attachment bool `json:"attachment"`
|
||||
// Embedded routes the downloaded file as an embed. Deprecated: use
|
||||
// Field instead. Kept for backward compatibility.
|
||||
Embedded bool `json:"embedded"`
|
||||
|
||||
// Field routes the downloaded file to a specific form field bucket.
|
||||
// Supported values: "watermark", "stamp". For embeds, prefer the
|
||||
// Embedded flag or set Field to "embedded".
|
||||
Field string `json:"field"`
|
||||
}
|
||||
|
||||
// newContext returns a [Context] by parsing a "multipart/form-data" request.
|
||||
@@ -135,12 +143,12 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
|
||||
err := os.RemoveAll(ctx.dirPath)
|
||||
if err != nil {
|
||||
ctx.logger.ErrorContext(ctx, fmt.Sprintf("remove context's working directory: %s", err))
|
||||
ctx.logger.ErrorContext(context.Background(), fmt.Sprintf("remove context's working directory: %s", err))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ctx.logger.DebugContext(ctx, fmt.Sprintf("'%s' context's working directory removed", ctx.dirPath))
|
||||
ctx.logger.DebugContext(context.Background(), fmt.Sprintf("'%s' context's working directory removed", ctx.dirPath))
|
||||
ctx.cancelled = true
|
||||
}
|
||||
}()
|
||||
@@ -193,6 +201,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
ctx.values = form.Value
|
||||
ctx.files = make(map[string]string)
|
||||
ctx.filesByField = make(map[string][]string)
|
||||
ctx.diskToOriginal = make(map[string]string)
|
||||
|
||||
// First, try to download files listed in the "downloadFrom" form field, if
|
||||
// any.
|
||||
@@ -207,6 +216,15 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
)
|
||||
}
|
||||
|
||||
// Each goroutine writes to its own results slot. The main
|
||||
// goroutine merges into ctx.files, ctx.diskToOriginal, and
|
||||
// ctx.filesByField after eg.Wait() to avoid concurrent map
|
||||
// writes.
|
||||
type downloadFromResult struct {
|
||||
filename, path, formField string
|
||||
}
|
||||
results := make([]downloadFromResult, len(dls))
|
||||
|
||||
eg, _ := errgroup.WithContext(ctx)
|
||||
for i, dl := range dls {
|
||||
eg.Go(func() error {
|
||||
@@ -223,15 +241,27 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
)
|
||||
}
|
||||
|
||||
err := gotenberg.FilterDeadline(downloadFromCfg.allowList, downloadFromCfg.denyList, dl.Url, deadline)
|
||||
ipOpts := []gotenberg.DecideOption{
|
||||
gotenberg.WithDenyPrivateIPs(downloadFromCfg.denyPrivateIPs),
|
||||
gotenberg.WithDenyPublicIPs(downloadFromCfg.denyPublicIPs),
|
||||
}
|
||||
err := gotenberg.FilterOutboundURL(ctx, dl.Url, downloadFromCfg.allowList, downloadFromCfg.denyList, deadline, ipOpts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("filter URL: %w", err)
|
||||
}
|
||||
|
||||
logger.DebugContext(ctx, fmt.Sprintf("download file from '%s'", dl.Url))
|
||||
dlCtx, dlSpan := gotenberg.Tracer().Start(ctx, "GET Download From",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(dl.Url)),
|
||||
)
|
||||
|
||||
req, err := retryablehttp.NewRequestWithContext(ctx, http.MethodGet, dl.Url, nil)
|
||||
logger.DebugContext(dlCtx, fmt.Sprintf("download file from '%s'", dl.Url))
|
||||
|
||||
req, err := retryablehttp.NewRequest(http.MethodGet, dl.Url, nil)
|
||||
if err != nil {
|
||||
dlSpan.RecordError(err)
|
||||
dlSpan.SetStatus(codes.Error, err.Error())
|
||||
dlSpan.End()
|
||||
return fmt.Errorf("create request to '%s': %w", dl.Url, err)
|
||||
}
|
||||
|
||||
@@ -239,45 +269,37 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
for key, value := range dl.ExtraHttpHeaders {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
req.Header.Set(echoCtx.Get("correlationIdHeader").(string), echoCtx.Get("correlationId").(string))
|
||||
|
||||
// OpenTelemetry.
|
||||
meter := gotenberg.Meter()
|
||||
semconvClient := semconvutil.NewHTTPClient(meter)
|
||||
// Inject OTEL trace context into outbound request.
|
||||
otel.GetTextMapPropagator().Inject(dlCtx, propagation.HeaderCarrier(req.Header))
|
||||
|
||||
tracer := gotenberg.Tracer()
|
||||
traceCtx, span := tracer.Start(ctx, fmt.Sprintf("%s Download From", req.Method),
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconvClient.RequestTraceAttrs(req.Request)...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
otel.GetTextMapPropagator().Inject(traceCtx, propagation.HeaderCarrier(req.Header))
|
||||
// Propagate correlation ID header.
|
||||
if correlationIdHeader, ok := echoCtx.Get("correlationIdHeader").(string); ok {
|
||||
if correlationId, ok := echoCtx.Get("correlationId").(string); ok {
|
||||
req.Header.Set(correlationIdHeader, correlationId)
|
||||
}
|
||||
}
|
||||
|
||||
client := &retryablehttp.Client{
|
||||
HTTPClient: &http.Client{
|
||||
Timeout: time.Until(deadline),
|
||||
},
|
||||
HTTPClient: gotenberg.NewOutboundHttpClient(time.Until(deadline), downloadFromCfg.allowList, downloadFromCfg.denyList, ipOpts...),
|
||||
RetryMax: downloadFromCfg.maxRetry,
|
||||
RetryWaitMin: time.Duration(1) * time.Second,
|
||||
RetryWaitMax: time.Until(deadline),
|
||||
Logger: gotenberg.NewLeveledLogger(logger).WithContext(ctx),
|
||||
Logger: gotenberg.NewLeveledLogger(logger),
|
||||
CheckRetry: retryablehttp.DefaultRetryPolicy,
|
||||
Backoff: retryablehttp.DefaultBackoff,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(semconvClient.Status(0))
|
||||
dlSpan.RecordError(err)
|
||||
dlSpan.SetStatus(codes.Error, err.Error())
|
||||
dlSpan.End()
|
||||
return WrapError(
|
||||
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)),
|
||||
)
|
||||
}
|
||||
|
||||
span.SetAttributes(semconvClient.ResponseTraceAttrs(resp)...)
|
||||
span.SetStatus(semconvClient.Status(resp.StatusCode))
|
||||
defer func() {
|
||||
err := resp.Body.Close()
|
||||
if err != nil {
|
||||
@@ -286,16 +308,24 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
dlErr := fmt.Errorf("download file from to '%s': got status: '%s'", dl.Url, resp.Status)
|
||||
dlSpan.RecordError(dlErr)
|
||||
dlSpan.SetStatus(codes.Error, dlErr.Error())
|
||||
dlSpan.End()
|
||||
return WrapError(
|
||||
fmt.Errorf("download file from to '%s': got status: '%s'", dl.Url, resp.Status),
|
||||
dlErr,
|
||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Unable to download file from '%s': got status: '%s'", dl.Url, resp.Status)),
|
||||
)
|
||||
}
|
||||
|
||||
contentDisposition := resp.Header.Get("Content-Disposition")
|
||||
if contentDisposition == "" {
|
||||
dlErr := fmt.Errorf("no 'Content-Disposition' header from '%s'", dl.Url)
|
||||
dlSpan.RecordError(dlErr)
|
||||
dlSpan.SetStatus(codes.Error, dlErr.Error())
|
||||
dlSpan.End()
|
||||
return WrapError(
|
||||
fmt.Errorf("no 'Content-Disposition' header from '%s'", dl.Url),
|
||||
dlErr,
|
||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("No 'Content-Disposition' header from '%s'", dl.Url)),
|
||||
)
|
||||
}
|
||||
@@ -305,29 +335,49 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
// See: https://github.com/golang/go/issues/69551.
|
||||
_, params, err := mime.ParseMediaType(contentDisposition)
|
||||
if err != nil {
|
||||
dlErr := fmt.Errorf("parse 'Content-Disposition' header '%s' from '%s': %w", contentDisposition, dl.Url, err)
|
||||
dlSpan.RecordError(dlErr)
|
||||
dlSpan.SetStatus(codes.Error, dlErr.Error())
|
||||
dlSpan.End()
|
||||
return WrapError(
|
||||
fmt.Errorf("parse 'Content-Disposition' header '%s' from '%s': %w", contentDisposition, dl.Url, err),
|
||||
dlErr,
|
||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Content-Disposition' header '%s' from '%s': %s", contentDisposition, dl.Url, err)),
|
||||
)
|
||||
}
|
||||
|
||||
filename, ok := params["filename"]
|
||||
if !ok {
|
||||
dlErr := fmt.Errorf("get filename from 'Content-Disposition' header '%s' from '%s'", contentDisposition, dl.Url)
|
||||
dlSpan.RecordError(dlErr)
|
||||
dlSpan.SetStatus(codes.Error, dlErr.Error())
|
||||
dlSpan.End()
|
||||
return WrapError(
|
||||
fmt.Errorf("get filename from 'Content-Disposition' header '%s' from '%s'", contentDisposition, dl.Url),
|
||||
dlErr,
|
||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Content-Disposition' header '%s' from '%s': no filename", contentDisposition, dl.Url)),
|
||||
)
|
||||
}
|
||||
|
||||
// Avoid directory traversal and make sure filename characters are
|
||||
// normalized.
|
||||
// Strip path separators (including backslashes) and control
|
||||
// characters, then NFC-normalize. Defends against directory
|
||||
// traversal in the on-disk name and Windows-side Zip Slip
|
||||
// when the original filename is later embedded in an output
|
||||
// zip entry.
|
||||
// See: https://github.com/gotenberg/gotenberg/issues/662.
|
||||
filename = norm.NFC.String(filepath.Base(filename))
|
||||
path := fmt.Sprintf("%s/%s", dirPath, filename)
|
||||
filename = sanitizeFilename(filename)
|
||||
|
||||
// Use a UUID-based name on disk to avoid filesystem
|
||||
// NAME_MAX limits with long filenames.
|
||||
// See: https://github.com/gotenberg/gotenberg/issues/1500.
|
||||
safeName := uuid.New().String() + filepath.Ext(filename)
|
||||
path := fmt.Sprintf("%s/%s", ctx.dirPath, safeName)
|
||||
|
||||
out, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create local file: %w", err)
|
||||
dlErr := fmt.Errorf("create local file: %w", err)
|
||||
dlSpan.RecordError(dlErr)
|
||||
dlSpan.SetStatus(codes.Error, dlErr.Error())
|
||||
dlSpan.End()
|
||||
return dlErr
|
||||
}
|
||||
defer func() {
|
||||
err := out.Close()
|
||||
@@ -341,13 +391,26 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
|
||||
_, err = io.Copy(out, reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("copy downloaded file from '%s' to local file: %w", dl.Url, err)
|
||||
dlErr := fmt.Errorf("copy downloaded file from '%s' to local file: %w", dl.Url, err)
|
||||
dlSpan.RecordError(dlErr)
|
||||
dlSpan.SetStatus(codes.Error, dlErr.Error())
|
||||
dlSpan.End()
|
||||
return dlErr
|
||||
}
|
||||
|
||||
ctx.files[filename] = path
|
||||
if dl.Attachment {
|
||||
ctx.filesByField[AttachmentsFormField] = append(ctx.filesByField[AttachmentsFormField], path)
|
||||
dlSpan.SetStatus(codes.Ok, "")
|
||||
dlSpan.End()
|
||||
|
||||
var formField string
|
||||
switch {
|
||||
case dl.Field == "embedded" || dl.Embedded:
|
||||
formField = EmbedsFormField
|
||||
case dl.Field == "watermark":
|
||||
formField = WatermarkFormField
|
||||
case dl.Field == "stamp":
|
||||
formField = StampFormField
|
||||
}
|
||||
results[i] = downloadFromResult{filename: filename, path: path, formField: formField}
|
||||
|
||||
return nil
|
||||
})
|
||||
@@ -357,6 +420,14 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
if err != nil {
|
||||
return ctx, cancel, err
|
||||
}
|
||||
|
||||
for _, r := range results {
|
||||
ctx.files[r.filename] = r.path
|
||||
ctx.diskToOriginal[r.path] = r.filename
|
||||
if r.formField != "" {
|
||||
ctx.filesByField[r.formField] = append(ctx.filesByField[r.formField], r.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
copyToDisk := func(fh *multipart.FileHeader) error {
|
||||
@@ -368,18 +439,25 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
defer func() {
|
||||
err := in.Close()
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, fmt.Sprintf("close file header: %s", err))
|
||||
logger.ErrorContext(context.Background(), fmt.Sprintf("close file header: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
// This will ensure we do not exceed the body limit.
|
||||
reader := &trackingReader{R: in, AddReadBytes: addReadBytes}
|
||||
|
||||
// Avoid directory traversal and make sure filename characters are
|
||||
// normalized.
|
||||
// Strip path separators (including backslashes) and control
|
||||
// characters, then NFC-normalize. Defends against directory
|
||||
// traversal in the on-disk name and Windows-side Zip Slip when the
|
||||
// original filename is later embedded in an output zip entry.
|
||||
// See: https://github.com/gotenberg/gotenberg/issues/662.
|
||||
filename := norm.NFC.String(filepath.Base(fh.Filename))
|
||||
path := fmt.Sprintf("%s/%s", ctx.dirPath, filename)
|
||||
filename := sanitizeFilename(fh.Filename)
|
||||
|
||||
// Use a UUID-based name on disk to avoid filesystem
|
||||
// NAME_MAX limits with long filenames.
|
||||
// See: https://github.com/gotenberg/gotenberg/issues/1500.
|
||||
safeName := uuid.New().String() + filepath.Ext(filename)
|
||||
path := fmt.Sprintf("%s/%s", ctx.dirPath, safeName)
|
||||
|
||||
out, err := os.Create(path)
|
||||
if err != nil {
|
||||
@@ -388,7 +466,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
defer func() {
|
||||
err := out.Close()
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, fmt.Sprintf("close local file: %s", err))
|
||||
logger.ErrorContext(context.Background(), fmt.Sprintf("close local file: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -398,6 +476,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
}
|
||||
|
||||
ctx.files[filename] = path
|
||||
ctx.diskToOriginal[path] = filename
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -410,12 +489,29 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
return ctx, cancel, fmt.Errorf("copy to disk: %w", err)
|
||||
}
|
||||
// Track files by field name
|
||||
filename := norm.NFC.String(filepath.Base(fh.Filename))
|
||||
filename := sanitizeFilename(fh.Filename)
|
||||
filePath := ctx.files[filename]
|
||||
ctx.filesByField[fieldName] = append(ctx.filesByField[fieldName], filePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Create symlinks from original filenames to UUID-based disk names
|
||||
// so that relative asset references (e.g., <img src="image.png">)
|
||||
// resolve correctly when Chromium navigates to a file:// URL.
|
||||
// Symlink creation is best-effort: it may fail for filenames that
|
||||
// exceed the filesystem NAME_MAX limit (the reason UUIDs were
|
||||
// introduced in the first place).
|
||||
for originalName, diskPath := range ctx.files {
|
||||
symlinkPath := fmt.Sprintf("%s/%s", ctx.dirPath, originalName)
|
||||
if symlinkPath == diskPath {
|
||||
continue
|
||||
}
|
||||
err = os.Symlink(filepath.Base(diskPath), symlinkPath)
|
||||
if err != nil {
|
||||
logger.DebugContext(context.Background(), fmt.Sprintf("skip symlink for '%s': %s", originalName, err))
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Log().DebugContext(ctx, fmt.Sprintf("form fields: %+v", ctx.values))
|
||||
ctx.Log().DebugContext(ctx, fmt.Sprintf("form files: %+v", ctx.files))
|
||||
ctx.Log().DebugContext(ctx, fmt.Sprintf("form files by field: %+v", ctx.filesByField))
|
||||
@@ -432,13 +528,34 @@ func (ctx *Context) Request() *http.Request {
|
||||
// FormData return a [FormData].
|
||||
func (ctx *Context) FormData() *FormData {
|
||||
return &FormData{
|
||||
values: ctx.values,
|
||||
files: ctx.files,
|
||||
filesByField: ctx.filesByField,
|
||||
errors: nil,
|
||||
values: ctx.values,
|
||||
files: ctx.files,
|
||||
filesByField: ctx.filesByField,
|
||||
diskToOriginal: ctx.diskToOriginal,
|
||||
errors: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// OriginalFilename returns the original filename associated with a disk path.
|
||||
// If no mapping exists, it falls back to [filepath.Base].
|
||||
func (ctx *Context) OriginalFilename(diskPath string) string {
|
||||
if original, ok := ctx.diskToOriginal[diskPath]; ok {
|
||||
return original
|
||||
}
|
||||
return filepath.Base(diskPath)
|
||||
}
|
||||
|
||||
// RegisterDiskPath associates a disk path with an original filename so that
|
||||
// [Context.OriginalFilename] can resolve it later.
|
||||
func (ctx *Context) RegisterDiskPath(diskPath, originalFilename string) {
|
||||
ctx.diskToOriginal[diskPath] = originalFilename
|
||||
}
|
||||
|
||||
// DirPath returns the path to the request's working directory.
|
||||
func (ctx *Context) DirPath() string {
|
||||
return ctx.dirPath
|
||||
}
|
||||
|
||||
// GeneratePath generates a path within the context's working directory.
|
||||
// It generates a new UUID-based filename. It does not create a file.
|
||||
func (ctx *Context) GeneratePath(extension string) string {
|
||||
@@ -446,10 +563,14 @@ func (ctx *Context) GeneratePath(extension string) string {
|
||||
}
|
||||
|
||||
// GeneratePathFromFilename generates a path within the context's working
|
||||
// directory, using the given filename (with extension). It does not create
|
||||
// a file.
|
||||
// directory. It uses a UUID-based name on disk to avoid filesystem NAME_MAX
|
||||
// limits but registers the given filename so that [Context.OriginalFilename]
|
||||
// can resolve it. It does not create a file.
|
||||
func (ctx *Context) GeneratePathFromFilename(filename string) string {
|
||||
return fmt.Sprintf("%s/%s", ctx.dirPath, filename)
|
||||
safeName := uuid.New().String() + filepath.Ext(filename)
|
||||
path := fmt.Sprintf("%s/%s", ctx.dirPath, safeName)
|
||||
ctx.diskToOriginal[path] = filename
|
||||
return path
|
||||
}
|
||||
|
||||
// CreateSubDirectory creates a subdirectory within the context's working
|
||||
@@ -516,7 +637,7 @@ func (ctx *Context) BuildOutputFile() (string, error) {
|
||||
filesInfo, err := archives.FilesFromDisk(ctx.Context, nil, func() map[string]string {
|
||||
f := make(map[string]string)
|
||||
for _, outputPath := range ctx.outputPaths {
|
||||
f[outputPath] = ""
|
||||
f[outputPath] = ctx.OriginalFilename(outputPath)
|
||||
}
|
||||
return f
|
||||
}())
|
||||
@@ -552,8 +673,26 @@ func (ctx *Context) OutputFilename(outputPath string) string {
|
||||
filename := ctx.echoCtx.Get("outputFilename").(string)
|
||||
|
||||
if filename == "" {
|
||||
return filepath.Base(outputPath)
|
||||
return ctx.OriginalFilename(outputPath)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))
|
||||
}
|
||||
|
||||
// sanitizeFilename strips path separators (including backslashes, which
|
||||
// [filepath.Base] ignores on Linux) and control characters from a
|
||||
// caller-supplied filename, then NFC-normalizes the result. This prevents a
|
||||
// Windows-side Zip Slip when an output zip is extracted by a permissive
|
||||
// extractor that interprets '\' as a path separator.
|
||||
func sanitizeFilename(name string) string {
|
||||
if i := strings.LastIndexAny(name, `/\`); i >= 0 {
|
||||
name = name[i+1:]
|
||||
}
|
||||
name = strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, name)
|
||||
return norm.NFC.String(name)
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ package api
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -36,7 +38,7 @@ func TestNewContext_Cancellation(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
||||
timeout := time.Duration(10) * time.Second
|
||||
downloadFromCfg := downloadFromConfig{
|
||||
@@ -70,3 +72,137 @@ func TestNewContext_Cancellation(t *testing.T) {
|
||||
t.Fatal("expected context to be cancelled after request context cancellation, but it timed out")
|
||||
}
|
||||
}
|
||||
|
||||
// Concurrent downloadFrom entries must not race on the shared maps
|
||||
// (ctx.files, ctx.diskToOriginal, ctx.filesByField). Run under -race
|
||||
// to catch the data race; without -race a sufficient number of entries
|
||||
// still surfaces "fatal error: concurrent map writes".
|
||||
func TestNewContext_DownloadFromConcurrentMapWrites(t *testing.T) {
|
||||
const downloads = 64
|
||||
|
||||
var ready sync.WaitGroup
|
||||
ready.Add(downloads)
|
||||
release := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ready.Done()
|
||||
go func() {
|
||||
ready.Wait()
|
||||
releaseOnce.Do(func() { close(release) })
|
||||
}()
|
||||
<-release
|
||||
|
||||
filename := fmt.Sprintf("download-%s.txt", r.URL.Query().Get("i"))
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
_, _ = w.Write([]byte("downloaded"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
dls := make([]downloadFrom, downloads)
|
||||
for i := range dls {
|
||||
dls[i] = downloadFrom{
|
||||
Url: fmt.Sprintf("%s/file?i=%d", server.URL, i),
|
||||
Field: "embedded",
|
||||
}
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(dls)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal downloadFrom payload: %v", err)
|
||||
}
|
||||
|
||||
body := new(bytes.Buffer)
|
||||
writer := multipart.NewWriter(body)
|
||||
err = writer.WriteField("downloadFrom", string(payload))
|
||||
if err != nil {
|
||||
t.Fatalf("write downloadFrom field: %v", err)
|
||||
}
|
||||
err = writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/forms/libreoffice/convert", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
echoCtx := echo.New().NewContext(req, httptest.NewRecorder())
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
||||
downloadFromCfg := downloadFromConfig{
|
||||
maxRetry: 0,
|
||||
}
|
||||
|
||||
ctx, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromCfg)
|
||||
if err != nil {
|
||||
t.Fatalf("newContext returned error: %v", err)
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
if got := len(ctx.files); got != downloads {
|
||||
t.Fatalf("downloaded files = %d, want %d", got, downloads)
|
||||
}
|
||||
if got := len(ctx.diskToOriginal); got != downloads {
|
||||
t.Fatalf("diskToOriginal entries = %d, want %d", got, downloads)
|
||||
}
|
||||
if got := len(ctx.filesByField[EmbedsFormField]); got != downloads {
|
||||
t.Fatalf("filesByField[%q] entries = %d, want %d", EmbedsFormField, got, downloads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFilename(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
input string
|
||||
expect string
|
||||
}{
|
||||
{
|
||||
scenario: "plain filename is unchanged",
|
||||
input: "report.pdf",
|
||||
expect: "report.pdf",
|
||||
},
|
||||
{
|
||||
scenario: "POSIX traversal is stripped",
|
||||
input: "../../etc/passwd",
|
||||
expect: "passwd",
|
||||
},
|
||||
{
|
||||
scenario: "Windows traversal with backslashes is stripped",
|
||||
input: `..\..\..\..\Windows\System32\evil.pdf`,
|
||||
expect: "evil.pdf",
|
||||
},
|
||||
{
|
||||
scenario: "mixed separators take the last segment",
|
||||
input: `foo/bar\baz.pdf`,
|
||||
expect: "baz.pdf",
|
||||
},
|
||||
{
|
||||
scenario: "control characters are dropped",
|
||||
input: "evil\x00\x07\x1f\x7f.pdf",
|
||||
expect: "evil.pdf",
|
||||
},
|
||||
{
|
||||
scenario: "NFC normalization collapses decomposed sequences",
|
||||
// "e" + combining acute accent -> precomposed "é".
|
||||
input: "café.pdf",
|
||||
expect: "café.pdf",
|
||||
},
|
||||
{
|
||||
scenario: "trailing backslash yields empty name",
|
||||
input: `foo\`,
|
||||
expect: "",
|
||||
},
|
||||
{
|
||||
scenario: "empty input yields empty name",
|
||||
input: "",
|
||||
expect: "",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
got := sanitizeFilename(tc.input)
|
||||
if got != tc.expect {
|
||||
t.Errorf("sanitizeFilename(%q) = %q, want %q", tc.input, got, tc.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// Package api provides a module, which is an HTTP server. Other modules may
|
||||
// add multipart/form-data routes, middlewares, and health checks.
|
||||
// Package api implements the HTTP server module. Other modules register
|
||||
// multipart/form data routes, middlewares, and health checks through it.
|
||||
package api
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
@@ -12,14 +14,18 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/multierr"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
// AttachmentsFormField represents the form field name for attaching files.
|
||||
const (
|
||||
AttachmentsFormField string = "attachments"
|
||||
// EmbedsFormField represents the form field name for embedding files.
|
||||
EmbedsFormField string = "embeds"
|
||||
|
||||
// WatermarkFormField represents the form field name for the watermark file.
|
||||
WatermarkFormField string = "watermark"
|
||||
|
||||
// StampFormField represents the form field name for the stamp file.
|
||||
StampFormField string = "stamp"
|
||||
)
|
||||
|
||||
// FormData is a helper for validating and hydrating values from a
|
||||
@@ -27,10 +33,11 @@ const (
|
||||
//
|
||||
// form := ctx.FormData()
|
||||
type FormData struct {
|
||||
values map[string][]string
|
||||
files map[string]string
|
||||
filesByField map[string][]string
|
||||
errors error
|
||||
values map[string][]string
|
||||
files map[string]string
|
||||
filesByField map[string][]string
|
||||
diskToOriginal map[string]string
|
||||
errors error
|
||||
}
|
||||
|
||||
// Validate returns nil or an error related to the [FormData] values, with a
|
||||
@@ -365,26 +372,58 @@ func (form *FormData) Paths(extensions []string, target *[]string) *FormData {
|
||||
return form.paths(extensions, target)
|
||||
}
|
||||
|
||||
// Attachments binds the absolute paths of form data files that should be
|
||||
// attached in the PDF. Only files uploaded with the "attachments" field name
|
||||
// Embeds binds the absolute paths of form data files that should be
|
||||
// embedded in the PDF. Only files uploaded with the "embeds" field name
|
||||
// will be included.
|
||||
//
|
||||
// var attachments []string
|
||||
// var embeds []string
|
||||
//
|
||||
// ctx.FormData().Attachments(&attachments)
|
||||
func (form *FormData) Attachments(target *[]string) *FormData {
|
||||
// ctx.FormData().Embeds(&embeds)
|
||||
func (form *FormData) Embeds(target *[]string) *FormData {
|
||||
if form.errors != nil {
|
||||
return form
|
||||
}
|
||||
|
||||
// Get files from the "attachments" field
|
||||
if paths, ok := form.filesByField[AttachmentsFormField]; ok {
|
||||
// Get files from the "embeds" field
|
||||
if paths, ok := form.filesByField[EmbedsFormField]; ok {
|
||||
*target = append(*target, paths...)
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// EmbedsMetadata parses the "embedsMetadata" form field (a JSON string) into
|
||||
// a map keyed by filename. Each value is a map of property names to values
|
||||
// (e.g., "mimeType" and "relationship").
|
||||
//
|
||||
// var metadata map[string]map[string]string
|
||||
//
|
||||
// ctx.FormData().EmbedsMetadata(&metadata)
|
||||
func (form *FormData) EmbedsMetadata(target *map[string]map[string]string) *FormData {
|
||||
if form.errors != nil {
|
||||
return form
|
||||
}
|
||||
|
||||
val, ok := form.values["embedsMetadata"]
|
||||
if !ok || len(val) == 0 || val[0] == "" {
|
||||
return form
|
||||
}
|
||||
|
||||
raw := val[0]
|
||||
parsed := make(map[string]map[string]string)
|
||||
|
||||
err := json.Unmarshal([]byte(raw), &parsed)
|
||||
if err != nil {
|
||||
form.append(
|
||||
fmt.Errorf("form field 'embedsMetadata' is invalid: %w", err),
|
||||
)
|
||||
return form
|
||||
}
|
||||
|
||||
*target = parsed
|
||||
return form
|
||||
}
|
||||
|
||||
// MandatoryPaths binds the absolute paths of form data files, according to a
|
||||
// list of file extensions, to a string slice variable. It populates an error
|
||||
// if there is no file for given file extensions.
|
||||
@@ -406,34 +445,96 @@ func (form *FormData) MandatoryPaths(extensions []string, target *[]string) *For
|
||||
return form
|
||||
}
|
||||
|
||||
// Watermark binds the absolute path of the form data file that should be
|
||||
// used as a watermark source. Only a file uploaded with the "watermark"
|
||||
// field name will be included.
|
||||
func (form *FormData) Watermark(target *string) *FormData {
|
||||
if form.errors != nil {
|
||||
return form
|
||||
}
|
||||
|
||||
if paths, ok := form.filesByField[WatermarkFormField]; ok && len(paths) > 0 {
|
||||
*target = paths[0]
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// Stamp binds the absolute path of the form data file that should be
|
||||
// used as a stamp source. Only a file uploaded with the "stamp"
|
||||
// field name will be included.
|
||||
func (form *FormData) Stamp(target *string) *FormData {
|
||||
if form.errors != nil {
|
||||
return form
|
||||
}
|
||||
|
||||
if paths, ok := form.filesByField[StampFormField]; ok && len(paths) > 0 {
|
||||
*target = paths[0]
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// paths bind the absolute paths of form data files, according to a list of
|
||||
// file extensions, to a string slice variable.
|
||||
// attachments are excluded.
|
||||
// embeds, watermark, and stamp files are excluded.
|
||||
func (form *FormData) paths(extensions []string, target *[]string) *FormData {
|
||||
attachments, ok := form.filesByField[AttachmentsFormField]
|
||||
embeds, ok := form.filesByField[EmbedsFormField]
|
||||
watermarks, wmOk := form.filesByField[WatermarkFormField]
|
||||
stamps, stOk := form.filesByField[StampFormField]
|
||||
|
||||
// Collect (originalFilename, diskPath) pairs so that we can sort by
|
||||
// original filename rather than by UUID-based disk name.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1500.
|
||||
type entry struct {
|
||||
original string
|
||||
disk string
|
||||
}
|
||||
var entries []entry
|
||||
|
||||
for filename, path := range form.files {
|
||||
if ok && slices.Contains(attachments, path) {
|
||||
if ok && slices.Contains(embeds, path) {
|
||||
continue
|
||||
}
|
||||
|
||||
if wmOk && slices.Contains(watermarks, path) {
|
||||
continue
|
||||
}
|
||||
|
||||
if stOk && slices.Contains(stamps, path) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ext := range extensions {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/228.
|
||||
if strings.ToLower(filepath.Ext(filename)) == ext {
|
||||
*target = append(*target, path)
|
||||
entries = append(entries, entry{original: filename, disk: path})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/139.
|
||||
sort.Sort(gotenberg.AlphanumericSort(*target))
|
||||
originals := make(gotenberg.AlphanumericSort, len(entries))
|
||||
for i, e := range entries {
|
||||
originals[i] = e.original
|
||||
}
|
||||
sort.Sort(originals)
|
||||
|
||||
// Build a lookup from original name to disk path.
|
||||
lookup := make(map[string]string, len(entries))
|
||||
for _, e := range entries {
|
||||
lookup[e.original] = e.disk
|
||||
}
|
||||
for _, o := range originals {
|
||||
*target = append(*target, lookup[o])
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// append adds an error to the list of errors.
|
||||
func (form *FormData) append(err error) {
|
||||
form.errors = multierr.Append(form.errors, err)
|
||||
form.errors = errors.Join(form.errors, err)
|
||||
}
|
||||
|
||||
// mustValue binds the target interface with a form field. If the value is
|
||||
|
||||
@@ -1613,15 +1613,15 @@ func TestFormData_Paths(t *testing.T) {
|
||||
expectCount: 2,
|
||||
},
|
||||
{
|
||||
scenario: "files except attachments",
|
||||
scenario: "files except embeds",
|
||||
form: &FormData{
|
||||
files: map[string]string{
|
||||
"foo.pdf": "/foo.pdf",
|
||||
"attachments_1.pdf": "/attachments_1.pdf",
|
||||
"attachments_2.xml": "/attachments_2.xml",
|
||||
"foo.pdf": "/foo.pdf",
|
||||
"embed_1.pdf": "/embed_1.pdf",
|
||||
"embed_2.xml": "/embed_2.xml",
|
||||
},
|
||||
filesByField: map[string][]string{
|
||||
"attachments": {"/attachments_1.pdf", "/attachments_2.xml"},
|
||||
"embeds": {"/embed_1.pdf", "/embed_2.xml"},
|
||||
},
|
||||
},
|
||||
extensions: []string{".pdf"},
|
||||
@@ -1759,7 +1759,7 @@ func TestFormData_mustAssign(t *testing.T) {
|
||||
form.mustAssign("foo", "foo", &target)
|
||||
}
|
||||
|
||||
func TestFormData_Attachments(t *testing.T) {
|
||||
func TestFormData_Embeds(t *testing.T) {
|
||||
expected := []string{"/bar.xml", "/baz.xml"}
|
||||
|
||||
var actual []string
|
||||
@@ -1770,13 +1770,13 @@ func TestFormData_Attachments(t *testing.T) {
|
||||
"baz.xml": "/baz.xml",
|
||||
},
|
||||
filesByField: map[string][]string{
|
||||
"attachments": {"/bar.xml", "/baz.xml"},
|
||||
"embeds": {"/bar.xml", "/baz.xml"},
|
||||
},
|
||||
}
|
||||
form.Attachments(&actual)
|
||||
form.Embeds(&actual)
|
||||
|
||||
if len(actual) != len(expected) {
|
||||
t.Errorf("expected %d attachments but got %d", len(expected), len(actual))
|
||||
t.Errorf("expected %d embeds but got %d", len(expected), len(actual))
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(actual, expected) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
@@ -67,8 +66,15 @@ func ParseError(err error) (int, string) {
|
||||
return http.StatusBadRequest, "At least one PDF engine cannot process the requested metadata, while others may have failed to convert due to different issues"
|
||||
}
|
||||
|
||||
var invalidArgsError *gotenberg.PdfEngineInvalidArgsError
|
||||
if errors.As(err, &invalidArgsError) {
|
||||
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"
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
if invalidArgsError, ok := errors.AsType[*gotenberg.PdfEngineInvalidArgsError](err); ok {
|
||||
return http.StatusBadRequest, invalidArgsError.Error()
|
||||
}
|
||||
|
||||
@@ -197,7 +203,7 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
|
||||
}
|
||||
|
||||
if skipTelemetry {
|
||||
c.Set("logger", slog.New(slog.NewJSONHandler(io.Discard, nil)))
|
||||
c.Set("logger", slog.New(slog.DiscardHandler))
|
||||
|
||||
err := next(c)
|
||||
if err != nil {
|
||||
@@ -331,7 +337,10 @@ func basicAuthMiddleware(username, password string) echo.MiddlewareFunc {
|
||||
func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
logger := c.Get("logger").(*slog.Logger)
|
||||
logger, _ := c.Get("logger").(*slog.Logger)
|
||||
if logger == nil {
|
||||
return errors.New("no logger in context (possible pool reuse)")
|
||||
}
|
||||
|
||||
// We create a context with a timeout so that underlying processes are
|
||||
// able to stop early and correctly handle a timeout scenario.
|
||||
@@ -389,7 +398,14 @@ func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimi
|
||||
func hardTimeoutMiddleware(hardTimeout time.Duration) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
logger := c.Get("logger").(*slog.Logger)
|
||||
// Guard the type assertion so a pooled [echo.Context] whose
|
||||
// store has been recycled under us does not crash the process.
|
||||
// See the webhook async handler for the race this protects
|
||||
// against.
|
||||
logger, _ := c.Get("logger").(*slog.Logger)
|
||||
if logger == nil {
|
||||
return errors.New("no logger in context (possible pool reuse)")
|
||||
}
|
||||
|
||||
// Define a hard timeout if the route handler fails to timeout as
|
||||
// expected.
|
||||
|
||||
39
pkg/modules/api/middlewares_test.go
Normal file
39
pkg/modules/api/middlewares_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func TestHardTimeoutMiddleware_MissingLoggerReturnsErrorInsteadOfPanicking(t *testing.T) {
|
||||
mw := hardTimeoutMiddleware(100 * time.Millisecond)
|
||||
handler := mw(func(c echo.Context) error { return nil })
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
// c has no "logger" key, mimicking a pooled context whose store was
|
||||
// recycled under a concurrently running webhook goroutine. The
|
||||
// middleware must surface an error instead of panicking on the
|
||||
// unchecked type assertion the pre-fix code relied on.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("hardTimeoutMiddleware panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
err := handler(c)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for missing logger, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "logger") {
|
||||
t.Fatalf("error = %q, want a message mentioning logger", err)
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@ func (ctx *ContextMock) OutputPaths() []string {
|
||||
// SetLogger sets the logger.
|
||||
//
|
||||
// ctx := &api.ContextMock{Context: &api.Context{}}
|
||||
// ctx.SetLogger(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
|
||||
// ctx.SetLogger(slog.Default())
|
||||
func (ctx *ContextMock) SetLogger(logger *slog.Logger) {
|
||||
ctx.logger = logger
|
||||
}
|
||||
|
||||
@@ -19,10 +19,6 @@ import (
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/shirou/gopsutil/v4/process"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.39.0"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
@@ -46,8 +42,10 @@ type browserArguments struct {
|
||||
hyphenDataDirPath string
|
||||
|
||||
// Tasks specific.
|
||||
allowList *regexp2.Regexp
|
||||
denyList *regexp2.Regexp
|
||||
allowList []*regexp2.Regexp
|
||||
denyList []*regexp2.Regexp
|
||||
denyPrivateIPs bool
|
||||
denyPublicIPs bool
|
||||
clearCache bool
|
||||
clearCookies bool
|
||||
disableJavaScript bool
|
||||
@@ -61,15 +59,17 @@ type chromiumBrowser struct {
|
||||
ctxMu sync.RWMutex
|
||||
isStarted atomic.Bool
|
||||
|
||||
arguments browserArguments
|
||||
fs *gotenberg.FileSystem
|
||||
arguments browserArguments
|
||||
fs *gotenberg.FileSystem
|
||||
pinningProxy *pinningProxy
|
||||
}
|
||||
|
||||
func newChromiumBrowser(arguments browserArguments) browser {
|
||||
b := &chromiumBrowser{
|
||||
initialCtx: context.Background(),
|
||||
arguments: arguments,
|
||||
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
|
||||
initialCtx: context.Background(),
|
||||
arguments: arguments,
|
||||
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
|
||||
pinningProxy: newPinningProxy(arguments.allowList, arguments.denyList, arguments.denyPrivateIPs, arguments.denyPublicIPs),
|
||||
}
|
||||
b.isStarted.Store(false)
|
||||
|
||||
@@ -81,10 +81,7 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
||||
return errors.New("browser is already started")
|
||||
}
|
||||
|
||||
debug := &debugLogger{
|
||||
ctx: b.initialCtx,
|
||||
logger: logger,
|
||||
}
|
||||
debug := &debugLogger{logger: logger}
|
||||
b.userProfileDirPath = b.fs.NewDirPath()
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1293.
|
||||
@@ -143,6 +140,25 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
||||
opts = append(opts, chromedp.ProxyServer(b.arguments.proxyServer))
|
||||
}
|
||||
|
||||
// Default: route Chromium through the internal pinning proxy so that
|
||||
// Chromium never performs its own DNS lookup for the navigation URL
|
||||
// or any sub-resource. The proxy resolves and validates each URL
|
||||
// once per request and dials the pinned IP, closing the DNS
|
||||
// rebinding window between Gotenberg's validation and Chromium's
|
||||
// connect.
|
||||
//
|
||||
// Skip when the operator has configured their own egress proxy or
|
||||
// custom host-resolver mappings: those deployments take
|
||||
// responsibility for outbound safety themselves and routing through
|
||||
// an internal proxy would override their configuration.
|
||||
if b.arguments.proxyServer == "" && b.arguments.hostResolverRules == "" {
|
||||
err = b.pinningProxy.Start(logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("start pinning proxy: %w", err)
|
||||
}
|
||||
opts = append(opts, chromedp.ProxyServer(b.pinningProxy.URL()))
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/524.
|
||||
opts = append(opts, chromedp.WSURLReadTimeout(b.arguments.wsUrlReadTimeout))
|
||||
|
||||
@@ -153,6 +169,15 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
||||
if err != nil {
|
||||
cancel()
|
||||
allocatorCancel()
|
||||
// The pinning proxy started before chromedp; tear it down so a
|
||||
// supervisor retry can re-bind. Stop is a no-op when the proxy
|
||||
// was never started (operator-configured --chromium-proxy-server
|
||||
// or --chromium-host-resolver-rules).
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1559.
|
||||
stopErr := b.pinningProxy.Stop(logger)
|
||||
if stopErr != nil {
|
||||
logger.ErrorContext(context.Background(), fmt.Sprintf("stop pinning proxy after failed start: %s", stopErr))
|
||||
}
|
||||
return fmt.Errorf("run exec allocator: %w", err)
|
||||
}
|
||||
|
||||
@@ -206,9 +231,9 @@ func (b *chromiumBrowser) Stop(logger *slog.Logger) error {
|
||||
|
||||
err = p.KillWithContext(killCtx)
|
||||
if err != nil {
|
||||
logger.ErrorContext(killCtx, fmt.Sprintf("kill process: %v", err))
|
||||
logger.ErrorContext(context.Background(), fmt.Sprintf("kill process: %v", err))
|
||||
} else {
|
||||
logger.DebugContext(killCtx, fmt.Sprintf("Chromium process %d killed", p.Pid))
|
||||
logger.DebugContext(context.Background(), fmt.Sprintf("Chromium process %d killed", p.Pid))
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -243,6 +268,15 @@ func (b *chromiumBrowser) Stop(logger *slog.Logger) error {
|
||||
b.userProfileDirPath = ""
|
||||
b.isStarted.Store(false)
|
||||
|
||||
// Stop the pinning proxy after Chromium shutdown so that any
|
||||
// in-flight requests Chromium issues during teardown complete. The
|
||||
// Stop call is a no-op when the proxy was not started (operator
|
||||
// configured --chromium-proxy-server or --chromium-host-resolver-rules).
|
||||
err := b.pinningProxy.Stop(logger)
|
||||
if err != nil {
|
||||
logger.ErrorContext(context.Background(), fmt.Sprintf("stop pinning proxy: %s", err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -273,7 +307,7 @@ func (b *chromiumBrowser) Healthy(logger *slog.Logger) bool {
|
||||
return err
|
||||
}))
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, fmt.Sprintf("browser health check failed: %s", err))
|
||||
logger.ErrorContext(context.Background(), fmt.Sprintf("browser health check failed: %s", err))
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -326,7 +360,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, u
|
||||
waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector),
|
||||
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
|
||||
// Screenshot specific.
|
||||
setDeviceMetricsOverride(logger, options.Width, options.Height),
|
||||
setDeviceMetricsOverride(logger, options.Width, options.Height, options.DeviceScaleFactor),
|
||||
captureScreenshotActionFunc(logger, outputPath, options),
|
||||
// Teardown.
|
||||
page.Close(),
|
||||
@@ -343,8 +377,12 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
||||
return errors.New("context has no deadline")
|
||||
}
|
||||
|
||||
// We validate the "main" URL against our allowed / deny lists.
|
||||
err := gotenberg.FilterDeadline(b.arguments.allowList, b.arguments.denyList, url, deadline)
|
||||
// We validate the "main" URL against our allowed / deny lists, and
|
||||
// against the IP-based outbound URL guard. See [gotenberg.FilterOutboundURL].
|
||||
err := gotenberg.FilterOutboundURL(ctx, url, b.arguments.allowList, b.arguments.denyList, deadline,
|
||||
gotenberg.WithDenyPrivateIPs(b.arguments.denyPrivateIPs),
|
||||
gotenberg.WithDenyPublicIPs(b.arguments.denyPublicIPs),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("filter URL: %w", err)
|
||||
}
|
||||
@@ -363,9 +401,12 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
||||
// the extra HTTP headers, if any.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1011.
|
||||
listenForEventRequestPaused(taskCtx, logger, eventRequestPausedOptions{
|
||||
allowList: b.arguments.allowList,
|
||||
denyList: b.arguments.denyList,
|
||||
extraHttpHeaders: options.ExtraHttpHeaders,
|
||||
allowList: b.arguments.allowList,
|
||||
denyList: b.arguments.denyList,
|
||||
denyPrivateIPs: b.arguments.denyPrivateIPs,
|
||||
denyPublicIPs: b.arguments.denyPublicIPs,
|
||||
allowedFilePrefixes: options.AllowedFilePrefixes,
|
||||
extraHttpHeaders: options.ExtraHttpHeaders,
|
||||
})
|
||||
|
||||
var (
|
||||
@@ -388,6 +429,7 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
||||
ignoreResourceHttpStatusDomains: options.IgnoreResourceHttpStatusDomains,
|
||||
invalidResourceHttpStatusCode: &invalidResourceHttpStatusCode,
|
||||
invalidResourceHttpStatusCodeMu: &invalidResourceHttpStatusCodeMu,
|
||||
cancelOnMainPageError: taskCancel,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -417,30 +459,45 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
||||
loadingFailedMu: &loadingFailedMu,
|
||||
resourceLoadingFailed: &resourceLoadingFailed,
|
||||
resourceLoadingFailedMu: &resourceLoadingFailedMu,
|
||||
cancelOnMainPageError: taskCancel,
|
||||
})
|
||||
|
||||
clientCtx, clientSpan := gotenberg.Tracer().Start(taskCtx, "cdp.execute",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(
|
||||
semconv.ServerAddress("127.0.0.1"),
|
||||
semconv.ServicePeerName("chromium"),
|
||||
semconv.RPCSystemNameKey.String("cdp"),
|
||||
// Legacy attribute for older APMs (Datadog, Jaeger) to draw the
|
||||
// dependency graph.
|
||||
attribute.String("peer.service", "chromium"),
|
||||
),
|
||||
)
|
||||
runErr := chromedp.Run(taskCtx, tasks...)
|
||||
|
||||
err = chromedp.Run(clientCtx, tasks...)
|
||||
if err != nil {
|
||||
clientSpan.RecordError(err)
|
||||
clientSpan.SetStatus(codes.Error, err.Error())
|
||||
// Check event-driven errors first — they take priority over chromedp.Run
|
||||
// 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,
|
||||
// chromedp.Run returns a context error that is less informative.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1492.
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/613.
|
||||
invalidHttpStatusCodeMu.RLock()
|
||||
defer invalidHttpStatusCodeMu.RUnlock()
|
||||
|
||||
if invalidHttpStatusCode != nil {
|
||||
return fmt.Errorf("%v: %w", invalidHttpStatusCode, ErrInvalidHttpStatusCode)
|
||||
}
|
||||
|
||||
clientSpan.End()
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1021.
|
||||
invalidResourceHttpStatusCodeMu.RLock()
|
||||
defer invalidResourceHttpStatusCodeMu.RUnlock()
|
||||
|
||||
if err != nil {
|
||||
errMessage := err.Error()
|
||||
if invalidResourceHttpStatusCode != nil {
|
||||
return fmt.Errorf("%v: %w", invalidResourceHttpStatusCode, ErrInvalidResourceHttpStatusCode)
|
||||
}
|
||||
|
||||
// See:
|
||||
// https://github.com/gotenberg/gotenberg/issues/913.
|
||||
// https://github.com/gotenberg/gotenberg/issues/959.
|
||||
loadingFailedMu.RLock()
|
||||
defer loadingFailedMu.RUnlock()
|
||||
|
||||
if loadingFailed != nil {
|
||||
return fmt.Errorf("%v: %w", loadingFailed, ErrLoadingFailed)
|
||||
}
|
||||
|
||||
if runErr != nil {
|
||||
errMessage := runErr.Error()
|
||||
|
||||
if strings.Contains(errMessage, "Printing failed (-32000)") {
|
||||
return ErrPrintingFailed
|
||||
@@ -462,23 +519,7 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
||||
return ErrRpccMessageTooLarge
|
||||
}
|
||||
|
||||
return fmt.Errorf("handle tasks: %w", err)
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/613.
|
||||
invalidHttpStatusCodeMu.RLock()
|
||||
defer invalidHttpStatusCodeMu.RUnlock()
|
||||
|
||||
if invalidHttpStatusCode != nil {
|
||||
return fmt.Errorf("%v: %w", invalidHttpStatusCode, ErrInvalidHttpStatusCode)
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1021.
|
||||
invalidResourceHttpStatusCodeMu.RLock()
|
||||
defer invalidResourceHttpStatusCodeMu.RUnlock()
|
||||
|
||||
if invalidResourceHttpStatusCode != nil {
|
||||
return fmt.Errorf("%v: %w", invalidResourceHttpStatusCode, ErrInvalidResourceHttpStatusCode)
|
||||
return fmt.Errorf("handle tasks: %w", runErr)
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/262.
|
||||
@@ -489,16 +530,6 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
||||
return fmt.Errorf("%v: %w", consoleExceptions, ErrConsoleExceptions)
|
||||
}
|
||||
|
||||
// See:
|
||||
// https://github.com/gotenberg/gotenberg/issues/913.
|
||||
// https://github.com/gotenberg/gotenberg/issues/959.
|
||||
loadingFailedMu.RLock()
|
||||
defer loadingFailedMu.RUnlock()
|
||||
|
||||
if loadingFailed != nil {
|
||||
return fmt.Errorf("%v: %w", loadingFailed, ErrLoadingFailed)
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1021.
|
||||
if options.FailOnResourceLoadingFailed {
|
||||
if resourceLoadingFailed != nil {
|
||||
|
||||
@@ -18,6 +18,8 @@ import (
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||
@@ -186,6 +188,15 @@ type Options struct {
|
||||
// OmitBackground hides the default white background and allows generating
|
||||
// PDFs with transparency.
|
||||
OmitBackground bool
|
||||
|
||||
// AllowedFilePrefixes restricts file:// sub-resource access to only
|
||||
// these directory prefixes. Applied in listenForEventRequestPaused in
|
||||
// addition to the global allow/deny lists. An empty slice
|
||||
// default-denies every file:// sub-resource, so routes that legitimately
|
||||
// render local files (HTML, Markdown) must populate this with the
|
||||
// request working directory while routes that navigate remote URLs
|
||||
// leave it empty. Set internally by route handlers, not via form data.
|
||||
AllowedFilePrefixes []string
|
||||
}
|
||||
|
||||
// EmulatedMediaFeature gathers the available entries for emulating a media
|
||||
@@ -283,7 +294,7 @@ type PdfOptions struct {
|
||||
PreferCssPageSize bool
|
||||
|
||||
// GenerateDocumentOutline defines whether the document outline should be
|
||||
// attached into the PDF.
|
||||
// embedded into the PDF.
|
||||
GenerateDocumentOutline bool
|
||||
|
||||
// GenerateTaggedPdf defines whether to generate tagged (accessible)
|
||||
@@ -339,18 +350,23 @@ type ScreenshotOptions struct {
|
||||
// OptimizeForSpeed defines whether to optimize image encoding for speed,
|
||||
// not for resulting size.
|
||||
OptimizeForSpeed bool
|
||||
|
||||
// DeviceScaleFactor is the ratio of the resolution in physical pixels to
|
||||
// the resolution in CSS pixels for the current display device.
|
||||
DeviceScaleFactor float64
|
||||
}
|
||||
|
||||
// DefaultScreenshotOptions returns the default values for ScreenshotOptions.
|
||||
func DefaultScreenshotOptions() ScreenshotOptions {
|
||||
return ScreenshotOptions{
|
||||
Options: DefaultOptions(),
|
||||
Width: 800,
|
||||
Height: 600,
|
||||
Clip: false,
|
||||
Format: "png",
|
||||
Quality: 100,
|
||||
OptimizeForSpeed: false,
|
||||
Options: DefaultOptions(),
|
||||
Width: 800,
|
||||
Height: 600,
|
||||
Clip: false,
|
||||
Format: "png",
|
||||
Quality: 100,
|
||||
OptimizeForSpeed: false,
|
||||
DeviceScaleFactor: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,6 +443,7 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
|
||||
fs := flag.NewFlagSet("chromium", flag.ExitOnError)
|
||||
fs.Int64("chromium-restart-after", 100, "Number of conversions after which Chromium will automatically restart. Set to 0 to disable this feature")
|
||||
fs.Int64("chromium-max-queue-size", 0, "Maximum request queue size for Chromium. Set to 0 to disable this feature")
|
||||
fs.Duration("chromium-idle-shutdown-timeout", 0, "Shutdown Chromium after being idle for the given duration. Set to 0 to disable this feature")
|
||||
fs.Int64("chromium-max-concurrency", 6, "Maximum number of concurrent conversions. Chromium supports up to 6")
|
||||
fs.Bool("chromium-auto-start", false, "Automatically launch Chromium upon initialization if set to true; otherwise, Chromium will start at the time of the first conversion")
|
||||
fs.Duration("chromium-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for Chromium to start or restart")
|
||||
@@ -436,13 +453,22 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
|
||||
fs.Bool("chromium-allow-file-access-from-files", false, "Allow file:// URIs to read other file:// URIs")
|
||||
fs.String("chromium-host-resolver-rules", "", "Set custom mappings to the host resolver")
|
||||
fs.String("chromium-proxy-server", "", "Set the outbound proxy server; this switch only affects HTTP and HTTPS requests")
|
||||
fs.String("chromium-allow-list", "", "Set the allowed URLs for Chromium using a regular expression")
|
||||
fs.String("chromium-deny-list", `^file:(?!//\/tmp/).*`, "Set the denied URLs for Chromium using a regular expression")
|
||||
fs.StringSlice("chromium-allow-list", []string{}, "Set the allowed URLs for Chromium using regular expressions - supports multiple values")
|
||||
fs.StringSlice("chromium-deny-list", []string{`^file:(?!//\/tmp/).*`}, "Set the denied URLs for Chromium using regular expressions - supports multiple values")
|
||||
fs.Bool("chromium-deny-private-ips", false, "Reject URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted form input to mitigate SSRF against internal services")
|
||||
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-cookies", false, "Clear Chromium cookies between each conversion")
|
||||
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
|
||||
fs.Bool("chromium-disable-routes", false, "Disable the routes")
|
||||
|
||||
// Deprecated flags.
|
||||
fs.Bool("chromium-incognito", false, "Start Chromium with incognito mode")
|
||||
err := fs.MarkDeprecated("chromium-incognito", "this flag is ignored as it provides no benefits")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
New: func() gotenberg.Module { return new(Chromium) },
|
||||
@@ -477,8 +503,10 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
||||
wsUrlReadTimeout: flags.MustDuration("chromium-start-timeout"),
|
||||
hyphenDataDirPath: hyphenDataDirPath,
|
||||
|
||||
allowList: flags.MustRegexp("chromium-allow-list"),
|
||||
denyList: flags.MustRegexp("chromium-deny-list"),
|
||||
allowList: flags.MustRegexpSlice("chromium-allow-list"),
|
||||
denyList: flags.MustRegexpSlice("chromium-deny-list"),
|
||||
denyPrivateIPs: flags.MustBool("chromium-deny-private-ips"),
|
||||
denyPublicIPs: flags.MustBool("chromium-deny-public-ips"),
|
||||
clearCache: flags.MustBool("chromium-clear-cache"),
|
||||
clearCookies: flags.MustBool("chromium-clear-cookies"),
|
||||
disableJavaScript: flags.MustBool("chromium-disable-javascript"),
|
||||
@@ -489,7 +517,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
||||
|
||||
// Process.
|
||||
mod.browser = newChromiumBrowser(mod.args)
|
||||
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"), mod.maxConcurrency)
|
||||
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"))
|
||||
|
||||
// PDF Engine.
|
||||
provider, err := ctx.Module(new(gotenberg.PdfEngineProvider))
|
||||
@@ -502,104 +530,107 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
||||
}
|
||||
mod.engine = engine
|
||||
|
||||
// OpenTelemetry.
|
||||
// Metrics.
|
||||
meter := gotenberg.Meter()
|
||||
_, err = meter.Int64ObservableCounter(
|
||||
"chromium.process.restarts.total",
|
||||
metric.WithDescription("Current number of Chromium restarts."),
|
||||
metric.WithUnit("{restart}"),
|
||||
|
||||
// Observable gauges.
|
||||
_, err = meter.Int64ObservableGauge(
|
||||
"chromium.requests.active",
|
||||
metric.WithDescription("Current number of active Chromium requests"),
|
||||
metric.WithUnit("{request}"),
|
||||
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
|
||||
val := mod.supervisor.RestartsCount()
|
||||
o.Observe(val)
|
||||
o.Observe(mod.supervisor.ActiveTasksCount())
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create process restarts observable counter: %w", err)
|
||||
return fmt.Errorf("create chromium.requests.active gauge: %w", err)
|
||||
}
|
||||
|
||||
_, err = meter.Int64ObservableGauge(
|
||||
"chromium.requests.queue_size",
|
||||
metric.WithDescription("Current number of Chromium conversion requests waiting to be treated."),
|
||||
metric.WithDescription("Current number of Chromium conversion requests waiting to be treated"),
|
||||
metric.WithUnit("{request}"),
|
||||
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
|
||||
val := mod.supervisor.ReqQueueSize()
|
||||
o.Observe(val)
|
||||
o.Observe(mod.supervisor.ReqQueueSize())
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create requests queue size observable gauge: %w", err)
|
||||
return fmt.Errorf("create chromium.requests.queue_size gauge: %w", err)
|
||||
}
|
||||
|
||||
_, err = meter.Int64ObservableGauge(
|
||||
"chromium.requests.active",
|
||||
metric.WithDescription("Current number of Chromium conversion requests actively being processed."),
|
||||
metric.WithUnit("{request}"),
|
||||
_, err = meter.Int64ObservableCounter(
|
||||
"chromium.process.restarts.total",
|
||||
metric.WithDescription("Current number of Chromium restarts"),
|
||||
metric.WithUnit("{restart}"),
|
||||
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
|
||||
val := mod.supervisor.ActiveTasksCount()
|
||||
o.Observe(val)
|
||||
o.Observe(mod.supervisor.RestartsCount())
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create requests active observable gauge: %w", err)
|
||||
return fmt.Errorf("create chromium.process.restarts.total counter: %w", err)
|
||||
}
|
||||
|
||||
// Counters.
|
||||
mod.reqsCounter, err = meter.Int64Counter(
|
||||
"chromium.requests.total",
|
||||
metric.WithDescription("Total number of Chromium conversion requests."),
|
||||
metric.WithDescription("Total number of Chromium conversion requests"),
|
||||
metric.WithUnit("{request}"),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create requests total counter: %w", err)
|
||||
return fmt.Errorf("create chromium.requests.total counter: %w", err)
|
||||
}
|
||||
|
||||
mod.errsCounter, err = meter.Int64Counter(
|
||||
"chromium.errors.total",
|
||||
metric.WithDescription("Total number of Chromium errors."),
|
||||
metric.WithDescription("Total number of Chromium conversion errors"),
|
||||
metric.WithUnit("{error}"),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create errors total counter: %w", err)
|
||||
return fmt.Errorf("create chromium.errors.total counter: %w", err)
|
||||
}
|
||||
|
||||
// Histograms.
|
||||
durationBuckets := metric.WithExplicitBucketBoundaries(0.5, 1, 2, 5, 10, 30, 60)
|
||||
|
||||
mod.conversionDurationCounter, err = meter.Float64Histogram(
|
||||
"chromium.conversion.duration",
|
||||
metric.WithDescription("Duration of each HTML-to-PDF conversion."),
|
||||
metric.WithDescription("Duration of Chromium conversions"),
|
||||
metric.WithUnit("s"),
|
||||
metric.WithExplicitBucketBoundaries(0.5, 1, 2, 5, 10, 30, 60),
|
||||
durationBuckets,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create conversion duration histogram: %w", err)
|
||||
return fmt.Errorf("create chromium.conversion.duration histogram: %w", err)
|
||||
}
|
||||
|
||||
mod.queueWaitDurationCounter, err = meter.Float64Histogram(
|
||||
"chromium.queue.wait.duration",
|
||||
metric.WithDescription("Time a request spends waiting in the queue before processing starts."),
|
||||
metric.WithDescription("Duration of waiting in queue for Chromium conversions"),
|
||||
metric.WithUnit("s"),
|
||||
metric.WithExplicitBucketBoundaries(0.5, 1, 2, 5, 10, 30, 60),
|
||||
durationBuckets,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create queue wait duration histogram: %w", err)
|
||||
return fmt.Errorf("create chromium.queue.wait.duration histogram: %w", err)
|
||||
}
|
||||
|
||||
mod.pdfOutputSizeCounter, err = meter.Int64Histogram(
|
||||
"chromium.pdf.output.size",
|
||||
metric.WithDescription("Size of the generated PDF files."),
|
||||
metric.WithDescription("Size of PDF output from Chromium conversions"),
|
||||
metric.WithUnit("By"),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create pdf output size histogram: %w", err)
|
||||
return fmt.Errorf("create chromium.pdf.output.size histogram: %w", err)
|
||||
}
|
||||
|
||||
mod.imageOutputSizeCounter, err = meter.Int64Histogram(
|
||||
"chromium.image.output.size",
|
||||
metric.WithDescription("Size of the generated image files."),
|
||||
metric.WithDescription("Size of image output from Chromium screenshots"),
|
||||
metric.WithUnit("By"),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create image output size histogram: %w", err)
|
||||
return fmt.Errorf("create chromium.image.output.size histogram: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -681,6 +712,26 @@ func (mod *Chromium) Debug() map[string]any {
|
||||
return debug
|
||||
}
|
||||
|
||||
// Metrics returns the metrics.
|
||||
func (mod *Chromium) Metrics() ([]gotenberg.Metric, error) {
|
||||
return []gotenberg.Metric{
|
||||
{
|
||||
Name: "chromium_requests_queue_size",
|
||||
Description: "Current number of Chromium conversion requests waiting to be treated.",
|
||||
Read: func() float64 {
|
||||
return float64(mod.supervisor.ReqQueueSize())
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "chromium_restarts_count",
|
||||
Description: "Current number of Chromium restarts.",
|
||||
Read: func() float64 {
|
||||
return float64(mod.supervisor.RestartsCount())
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Checks adds a health check that verifies if Chromium is healthy.
|
||||
func (mod *Chromium) Checks() ([]health.CheckerOption, error) {
|
||||
return []health.CheckerOption{
|
||||
@@ -749,9 +800,10 @@ func (mod *Chromium) Routes() ([]api.Route, error) {
|
||||
|
||||
// Pdf converts a URL to PDF.
|
||||
func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
|
||||
// Note: no error wrapping because it leaks on errors we want to display to
|
||||
// the end user.
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "Chromium.Pdf")
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Pdf",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
start := time.Now()
|
||||
@@ -772,17 +824,15 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
|
||||
status = "error"
|
||||
}
|
||||
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
|
||||
reason := "unknown"
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
reason = "timeout"
|
||||
} else if errors.Is(err, context.Canceled) {
|
||||
case errors.Is(err, context.Canceled):
|
||||
reason = "context_cancelled"
|
||||
} else if errors.Is(err, ErrInvalidHttpStatusCode) || errors.Is(err, ErrInvalidResourceHttpStatusCode) || errors.Is(err, ErrLoadingFailed) || errors.Is(err, ErrResourceLoadingFailed) || errors.Is(err, ErrInvalidEvaluationExpression) || errors.Is(err, ErrInvalidSelectorQuery) {
|
||||
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"
|
||||
} else if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) || errors.Is(err, gotenberg.ErrProcessAlreadyRestarting) {
|
||||
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) || errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
|
||||
reason = "chromium_unavailable"
|
||||
}
|
||||
|
||||
@@ -816,15 +866,21 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
|
||||
mod.pdfOutputSizeCounter.Record(ctx, fileInfo.Size())
|
||||
}
|
||||
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
// Note: no error wrapping because it leaks on errors we want to display to
|
||||
// the end user.
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "Chromium.Screenshot")
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Screenshot",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
start := time.Now()
|
||||
@@ -845,19 +901,17 @@ func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, o
|
||||
status = "error"
|
||||
}
|
||||
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
|
||||
reason := "unknown"
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
reason = "timeout"
|
||||
} else if errors.Is(err, context.Canceled) {
|
||||
case errors.Is(err, context.Canceled):
|
||||
reason = "context_cancelled"
|
||||
} else if errors.Is(err, ErrInvalidHttpStatusCode) || errors.Is(err, ErrInvalidResourceHttpStatusCode) || errors.Is(err, ErrLoadingFailed) || errors.Is(err, ErrResourceLoadingFailed) || errors.Is(err, ErrInvalidEvaluationExpression) || errors.Is(err, ErrInvalidSelectorQuery) {
|
||||
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"
|
||||
} else if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
|
||||
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded):
|
||||
reason = "chromium_maximum_queue_size_exceeded"
|
||||
} else if errors.Is(err, gotenberg.ErrProcessAlreadyRestarting) {
|
||||
case errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
|
||||
reason = "chromium_unavailable"
|
||||
}
|
||||
|
||||
@@ -891,20 +945,26 @@ func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, o
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
|
||||
mod.imageOutputSizeCounter.Record(ctx, fileInfo.Size())
|
||||
}
|
||||
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*Chromium)(nil)
|
||||
_ gotenberg.Provisioner = (*Chromium)(nil)
|
||||
_ gotenberg.Validator = (*Chromium)(nil)
|
||||
_ gotenberg.App = (*Chromium)(nil)
|
||||
_ gotenberg.Debuggable = (*Chromium)(nil)
|
||||
_ api.HealthChecker = (*Chromium)(nil)
|
||||
_ api.Router = (*Chromium)(nil)
|
||||
_ Api = (*Chromium)(nil)
|
||||
_ Provider = (*Chromium)(nil)
|
||||
_ gotenberg.Module = (*Chromium)(nil)
|
||||
_ gotenberg.Provisioner = (*Chromium)(nil)
|
||||
_ gotenberg.Validator = (*Chromium)(nil)
|
||||
_ gotenberg.App = (*Chromium)(nil)
|
||||
_ gotenberg.Debuggable = (*Chromium)(nil)
|
||||
_ gotenberg.MetricsProvider = (*Chromium)(nil)
|
||||
_ api.HealthChecker = (*Chromium)(nil)
|
||||
_ api.Router = (*Chromium)(nil)
|
||||
_ Api = (*Chromium)(nil)
|
||||
_ Provider = (*Chromium)(nil)
|
||||
)
|
||||
|
||||
@@ -10,20 +10,19 @@ import (
|
||||
// debugLogger is wrapper around a [slog.Logger] which is used for debugging
|
||||
// Chromium.
|
||||
type debugLogger struct {
|
||||
ctx context.Context
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// Write logs the bytes in a debug message.
|
||||
func (debug *debugLogger) Write(p []byte) (n int, err error) {
|
||||
debug.logger.DebugContext(debug.ctx, string(p))
|
||||
debug.logger.DebugContext(context.Background(), string(p))
|
||||
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Printf logs a debug message.
|
||||
func (debug *debugLogger) Printf(format string, v ...any) {
|
||||
debug.logger.DebugContext(debug.ctx, fmt.Sprintf(format, v...))
|
||||
debug.logger.DebugContext(context.Background(), fmt.Sprintf(format, v...))
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package chromium provides a module which adds routes for converting HTML
|
||||
// documents to PDF. Other modules may also retrieve the [Api] provided by this
|
||||
// module.
|
||||
// Package chromium adds routes for converting HTML documents to PDF. Exposes
|
||||
// an [Api] for other modules.
|
||||
package chromium
|
||||
|
||||
@@ -2,6 +2,7 @@ package chromium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -17,14 +18,16 @@ import (
|
||||
"github.com/chromedp/cdproto/runtime"
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/dlclark/regexp2"
|
||||
"go.uber.org/multierr"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
type eventRequestPausedOptions struct {
|
||||
allowList, denyList *regexp2.Regexp
|
||||
allowList, denyList []*regexp2.Regexp
|
||||
denyPrivateIPs bool
|
||||
denyPublicIPs bool
|
||||
allowedFilePrefixes []string
|
||||
extraHttpHeaders []ExtraHttpHeader
|
||||
}
|
||||
|
||||
@@ -40,8 +43,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
|
||||
}
|
||||
|
||||
chromedp.ListenTarget(ctx, func(ev any) {
|
||||
switch e := ev.(type) {
|
||||
case *fetch.EventRequestPaused:
|
||||
if e, ok := ev.(*fetch.EventRequestPaused); ok {
|
||||
go func() {
|
||||
logger.DebugContext(ctx, fmt.Sprintf("event EventRequestPaused fired for '%s'", e.Request.URL))
|
||||
allow := true
|
||||
@@ -52,16 +54,42 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
|
||||
return
|
||||
}
|
||||
|
||||
err := gotenberg.FilterDeadline(options.allowList, options.denyList, e.Request.URL, deadline)
|
||||
err := gotenberg.FilterOutboundURL(ctx, e.Request.URL, options.allowList, options.denyList, deadline,
|
||||
gotenberg.WithDenyPrivateIPs(options.denyPrivateIPs),
|
||||
gotenberg.WithDenyPublicIPs(options.denyPublicIPs),
|
||||
)
|
||||
if err != nil {
|
||||
logger.WarnContext(ctx, err.Error())
|
||||
allow = false
|
||||
}
|
||||
|
||||
// Sub-resource file:// URLs are opt-in per route. A route
|
||||
// that renders local files (HTML, Markdown) populates
|
||||
// allowedFilePrefixes with the request working directory
|
||||
// so its own assets load while sibling requests' /tmp
|
||||
// paths stay out of reach. Every other route leaves the
|
||||
// slice empty; treat that as default-deny so a file://
|
||||
// sub-resource that slips past the deny-list (which
|
||||
// exempts /tmp/) still cannot read the working
|
||||
// directories of other in-flight conversions.
|
||||
if allow && strings.HasPrefix(e.Request.URL, "file://") && !isAllowedFileSubResource(e.Request.URL, options.allowedFilePrefixes) {
|
||||
logger.WarnContext(ctx, fmt.Sprintf("'%s' is not within any allowed file prefix", e.Request.URL))
|
||||
allow = false
|
||||
}
|
||||
|
||||
cctx := chromedp.FromContext(ctx)
|
||||
executorCtx := cdp.WithExecutor(ctx, cctx.Target)
|
||||
|
||||
if !allow {
|
||||
// Use AccessDenied so Chromium emits net::ERR_ACCESS_DENIED,
|
||||
// which is intentionally absent from the EventLoadingFailed
|
||||
// known-errors list. Routing through BlockedByClient would
|
||||
// surface the failure, but the Document-type dispatcher in
|
||||
// listenForEventLoadingFailed cannot distinguish a blocked
|
||||
// iframe (sub-frame Document) from a main-page Document, and
|
||||
// would attribute the iframe failure to the main page.
|
||||
// Filter-block observability is provided by the warn log
|
||||
// above instead.
|
||||
req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
|
||||
err = req.Do(executorCtx)
|
||||
if err != nil {
|
||||
@@ -87,12 +115,13 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
|
||||
}
|
||||
|
||||
ok, err := header.Scope.MatchString(e.Request.URL)
|
||||
if err != nil {
|
||||
switch {
|
||||
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))
|
||||
} else if ok {
|
||||
case ok:
|
||||
logger.DebugContext(ctx, fmt.Sprintf("extra HTTP header '%s' (scoped) will be set for request URL '%s'", header.Name, e.Request.URL))
|
||||
extraHttpHeadersToSet = append(extraHttpHeadersToSet, header)
|
||||
} else {
|
||||
default:
|
||||
logger.DebugContext(ctx, fmt.Sprintf("scoped extra HTTP header '%s' (scoped) will not be set for request URL '%s'", header.Name, e.Request.URL))
|
||||
}
|
||||
}
|
||||
@@ -148,6 +177,7 @@ type eventResponseReceivedOptions struct {
|
||||
ignoreResourceHttpStatusDomains []string
|
||||
invalidResourceHttpStatusCode *error
|
||||
invalidResourceHttpStatusCodeMu *sync.RWMutex
|
||||
cancelOnMainPageError context.CancelFunc
|
||||
}
|
||||
|
||||
// listenForEventResponseReceived listens for an invalid HTTP status code
|
||||
@@ -177,8 +207,7 @@ func listenForEventResponseReceived(
|
||||
}
|
||||
|
||||
chromedp.ListenTarget(ctx, func(ev any) {
|
||||
switch ev := ev.(type) {
|
||||
case *network.EventResponseReceived:
|
||||
if ev, ok := ev.(*network.EventResponseReceived); ok {
|
||||
if ev.Response.URL == options.mainPageUrl {
|
||||
logger.DebugContext(ctx, fmt.Sprintf("event EventResponseReceived fired for main page: %+v", ev.Response))
|
||||
|
||||
@@ -187,6 +216,14 @@ func listenForEventResponseReceived(
|
||||
defer options.invalidHttpStatusCodeMu.Unlock()
|
||||
|
||||
*options.invalidHttpStatusCode = fmt.Errorf("%d: %s", ev.Response.Status, ev.Response.StatusText)
|
||||
|
||||
// Cancel the task context so that any in-flight wait
|
||||
// operations (waitForSelector, waitForExpression, etc.)
|
||||
// abort immediately instead of polling until timeout.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1492.
|
||||
if options.cancelOnMainPageError != nil {
|
||||
options.cancelOnMainPageError()
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
@@ -203,7 +240,7 @@ func listenForEventResponseReceived(
|
||||
options.invalidResourceHttpStatusCodeMu.Lock()
|
||||
defer options.invalidResourceHttpStatusCodeMu.Unlock()
|
||||
|
||||
*options.invalidResourceHttpStatusCode = multierr.Append(
|
||||
*options.invalidResourceHttpStatusCode = errors.Join(
|
||||
*options.invalidResourceHttpStatusCode,
|
||||
fmt.Errorf("%s - %d: %s", ev.Response.URL, ev.Response.Status, http.StatusText(int(ev.Response.Status))),
|
||||
)
|
||||
@@ -212,6 +249,23 @@ func listenForEventResponseReceived(
|
||||
})
|
||||
}
|
||||
|
||||
// isAllowedFileSubResource reports whether a file:// sub-resource URL is
|
||||
// within at least one prefix. An empty prefix list rejects every
|
||||
// file:// URL so routes that never populate the list (for example
|
||||
// /forms/chromium/convert/url) default-deny reads from /tmp/, blocking
|
||||
// cross-request enumeration.
|
||||
func isAllowedFileSubResource(rawURL string, allowedFilePrefixes []string) bool {
|
||||
if len(allowedFilePrefixes) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range allowedFilePrefixes {
|
||||
if strings.HasPrefix(rawURL, "file://"+prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shouldCheckResourceHttpStatusCode(rawURL string, ignoreDomains []string) bool {
|
||||
host := hostnameFromURL(rawURL)
|
||||
|
||||
@@ -290,6 +344,7 @@ type eventLoadingFailedOptions struct {
|
||||
loadingFailedMu *sync.RWMutex
|
||||
resourceLoadingFailed *error
|
||||
resourceLoadingFailedMu *sync.RWMutex
|
||||
cancelOnMainPageError context.CancelFunc
|
||||
}
|
||||
|
||||
// listenForEventLoadingFailed listens for an event indicating that the main
|
||||
@@ -300,13 +355,12 @@ type eventLoadingFailedOptions struct {
|
||||
// https://github.com/gotenberg/gotenberg/issues/1021.
|
||||
func listenForEventLoadingFailed(ctx context.Context, logger *slog.Logger, options eventLoadingFailedOptions) {
|
||||
chromedp.ListenTarget(ctx, func(ev any) {
|
||||
switch ev := ev.(type) {
|
||||
case *network.EventLoadingFailed:
|
||||
if ev, ok := ev.(*network.EventLoadingFailed); ok {
|
||||
logger.DebugContext(ctx, fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText))
|
||||
|
||||
// We are looking for common errors.
|
||||
// TODO: sufficient?
|
||||
errors := []string{
|
||||
knownErrors := []string{
|
||||
"net::ERR_CONNECTION_CLOSED",
|
||||
"net::ERR_CONNECTION_RESET",
|
||||
"net::ERR_CONNECTION_REFUSED",
|
||||
@@ -320,8 +374,8 @@ func listenForEventLoadingFailed(ctx context.Context, logger *slog.Logger, optio
|
||||
"net::ERR_FILE_NOT_FOUND",
|
||||
"net::ERR_HTTP2_PROTOCOL_ERROR",
|
||||
}
|
||||
if !slices.Contains(errors, ev.ErrorText) {
|
||||
logger.DebugContext(ctx, fmt.Sprintf("skip EventLoadingFailed: '%s' is not part of %+v", ev.ErrorText, errors))
|
||||
if !slices.Contains(knownErrors, ev.ErrorText) {
|
||||
logger.DebugContext(ctx, fmt.Sprintf("skip EventLoadingFailed: '%s' is not part of %+v", ev.ErrorText, knownErrors))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -335,6 +389,13 @@ func listenForEventLoadingFailed(ctx context.Context, logger *slog.Logger, optio
|
||||
|
||||
*options.loadingFailed = fmt.Errorf("%s", ev.ErrorText)
|
||||
|
||||
// Cancel the task context so that any in-flight wait
|
||||
// operations abort immediately.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1492.
|
||||
if options.cancelOnMainPageError != nil {
|
||||
options.cancelOnMainPageError()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -343,7 +404,7 @@ func listenForEventLoadingFailed(ctx context.Context, logger *slog.Logger, optio
|
||||
options.resourceLoadingFailedMu.Lock()
|
||||
defer options.resourceLoadingFailedMu.Unlock()
|
||||
|
||||
*options.resourceLoadingFailed = multierr.Append(
|
||||
*options.resourceLoadingFailed = errors.Join(
|
||||
*options.resourceLoadingFailed,
|
||||
fmt.Errorf("resource %s: %s", ev.Type, ev.ErrorText),
|
||||
)
|
||||
@@ -356,32 +417,35 @@ func listenForEventLoadingFailed(ctx context.Context, logger *slog.Logger, optio
|
||||
// See https://github.com/gotenberg/gotenberg/issues/262.
|
||||
func listenForEventExceptionThrown(ctx context.Context, logger *slog.Logger, consoleExceptions *error, consoleExceptionsMu *sync.RWMutex) {
|
||||
chromedp.ListenTarget(ctx, func(ev any) {
|
||||
switch ev := ev.(type) {
|
||||
case *runtime.EventExceptionThrown:
|
||||
if ev, ok := ev.(*runtime.EventExceptionThrown); ok {
|
||||
logger.DebugContext(ctx, fmt.Sprintf("event EventExceptionThrown fired: %+v", ev.ExceptionDetails))
|
||||
|
||||
consoleExceptionsMu.Lock()
|
||||
defer consoleExceptionsMu.Unlock()
|
||||
|
||||
*consoleExceptions = multierr.Append(*consoleExceptions, fmt.Errorf("\n%+v", ev.ExceptionDetails))
|
||||
*consoleExceptions = errors.Join(*consoleExceptions, fmt.Errorf("\n%+v", ev.ExceptionDetails))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// waitForEventDomContentEventFired waits until the event DomContentEventFired
|
||||
// is fired or the context timeout.
|
||||
// waitForEventDomContentEventFired registers a listener for the
|
||||
// DomContentEventFired event and returns a waiter that blocks until the
|
||||
// event fires or ctx is done. The listener registers at call time, not
|
||||
// inside the waiter, so callers must invoke this before triggering the
|
||||
// action that may emit the event. Registering inside the waiter would
|
||||
// open a race: for fast loads (typically file:// pages with no external
|
||||
// sub-resources), the event can fire before the waiter goroutine starts
|
||||
// and the listener never sees a record.
|
||||
func waitForEventDomContentEventFired(ctx context.Context, logger *slog.Logger) func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev any) {
|
||||
if _, ok := ev.(*page.EventDomContentEventFired); ok {
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
})
|
||||
return func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev any) {
|
||||
switch ev.(type) {
|
||||
case *page.EventDomContentEventFired:
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
logger.DebugContext(ctx, "event DomContentEventFired fired")
|
||||
@@ -392,20 +456,20 @@ func waitForEventDomContentEventFired(ctx context.Context, logger *slog.Logger)
|
||||
}
|
||||
}
|
||||
|
||||
// waitForEventLoadEventFired waits until the event LoadEventFired is fired or
|
||||
// the context timeout.
|
||||
// waitForEventLoadEventFired registers a listener for the LoadEventFired
|
||||
// event and returns a waiter that blocks until the event fires or ctx is
|
||||
// done. See [waitForEventDomContentEventFired] for the rationale on
|
||||
// registering at call time rather than inside the waiter.
|
||||
func waitForEventLoadEventFired(ctx context.Context, logger *slog.Logger) func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev any) {
|
||||
if _, ok := ev.(*page.EventLoadEventFired); ok {
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
})
|
||||
return func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev any) {
|
||||
switch ev.(type) {
|
||||
case *page.EventLoadEventFired:
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
logger.DebugContext(ctx, "event LoadEventFired fired")
|
||||
@@ -416,22 +480,20 @@ func waitForEventLoadEventFired(ctx context.Context, logger *slog.Logger) func()
|
||||
}
|
||||
}
|
||||
|
||||
// waitForEventNetworkIdle waits until the event networkIdle is fired or the
|
||||
// context timeout.
|
||||
// waitForEventNetworkIdle registers a listener for the networkIdle
|
||||
// lifecycle event and returns a waiter that blocks until the event fires
|
||||
// or ctx is done. See [waitForEventDomContentEventFired] for the
|
||||
// rationale on registering at call time rather than inside the waiter.
|
||||
func waitForEventNetworkIdle(ctx context.Context, logger *slog.Logger) func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev any) {
|
||||
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle" {
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
})
|
||||
return func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev any) {
|
||||
switch e := ev.(type) {
|
||||
case *page.EventLifecycleEvent:
|
||||
if e.Name == "networkIdle" {
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
logger.DebugContext(ctx, "event networkIdle fired")
|
||||
@@ -442,22 +504,20 @@ func waitForEventNetworkIdle(ctx context.Context, logger *slog.Logger) func() er
|
||||
}
|
||||
}
|
||||
|
||||
// waitForEventNetworkAlmostIdle waits until the event networkIdle2 is fired
|
||||
// or the context timeout.
|
||||
// waitForEventNetworkAlmostIdle registers a listener for the networkIdle2
|
||||
// lifecycle event and returns a waiter that blocks until the event fires
|
||||
// or ctx is done. See [waitForEventDomContentEventFired] for the
|
||||
// rationale on registering at call time rather than inside the waiter.
|
||||
func waitForEventNetworkAlmostIdle(ctx context.Context, logger *slog.Logger) func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev any) {
|
||||
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle2" {
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
})
|
||||
return func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev any) {
|
||||
switch e := ev.(type) {
|
||||
case *page.EventLifecycleEvent:
|
||||
if e.Name == "networkIdle2" {
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
logger.DebugContext(ctx, "event networkAlmostIdle fired")
|
||||
@@ -468,20 +528,20 @@ func waitForEventNetworkAlmostIdle(ctx context.Context, logger *slog.Logger) fun
|
||||
}
|
||||
}
|
||||
|
||||
// waitForEventLoadingFinished waits until the event LoadingFinished is fired
|
||||
// or the context timeout.
|
||||
// waitForEventLoadingFinished registers a listener for the
|
||||
// LoadingFinished event and returns a waiter that blocks until the event
|
||||
// fires or ctx is done. See [waitForEventDomContentEventFired] for the
|
||||
// rationale on registering at call time rather than inside the waiter.
|
||||
func waitForEventLoadingFinished(ctx context.Context, logger *slog.Logger) func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev any) {
|
||||
if _, ok := ev.(*network.EventLoadingFinished); ok {
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
})
|
||||
return func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev any) {
|
||||
switch ev.(type) {
|
||||
case *network.EventLoadingFinished:
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
logger.DebugContext(ctx, "event LoadingFinished fired")
|
||||
|
||||
@@ -61,3 +61,49 @@ func TestShouldCheckResourceHttpStatusCode_NonHTTPURL(t *testing.T) {
|
||||
t.Fatalf("expected data: URL to be checked (no host filtering possible)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAllowedFileSubResource(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
rawURL string
|
||||
prefixes []string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "empty prefix list default denies",
|
||||
rawURL: "file:///tmp/work-uuid/request-uuid/index.html",
|
||||
prefixes: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "match within the sole prefix",
|
||||
rawURL: "file:///tmp/work-uuid/request-uuid/index.html",
|
||||
prefixes: []string{"/tmp/work-uuid/request-uuid"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "sibling request directory rejected",
|
||||
rawURL: "file:///tmp/work-uuid/other-request-uuid/secret.html",
|
||||
prefixes: []string{"/tmp/work-uuid/request-uuid"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "parent tmp directory rejected",
|
||||
rawURL: "file:///tmp/",
|
||||
prefixes: []string{"/tmp/work-uuid/request-uuid"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "match among several prefixes",
|
||||
rawURL: "file:///tmp/work-uuid/request-b/asset.css",
|
||||
prefixes: []string{"/tmp/work-uuid/request-a", "/tmp/work-uuid/request-b"},
|
||||
want: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isAllowedFileSubResource(tc.rawURL, tc.prefixes); got != tc.want {
|
||||
t.Fatalf("isAllowedFileSubResource(%q, %v) = %v, want %v", tc.rawURL, tc.prefixes, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
366
pkg/modules/chromium/pinning_proxy.go
Normal file
366
pkg/modules/chromium/pinning_proxy.go
Normal file
@@ -0,0 +1,366 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
// pinningProxy is a loopback-bound HTTP/1.1 forward and CONNECT proxy
|
||||
// placed between Chromium and the outbound network. It runs the same
|
||||
// allow/deny/IP-public validation as [gotenberg.FilterOutboundURL] on
|
||||
// every request and dials the destination using the IPs resolved at that
|
||||
// moment. Routing Chromium through this proxy eliminates the Chromium-side
|
||||
// DNS lookup that otherwise opens a DNS rebinding window between
|
||||
// Gotenberg's validation and Chromium's TCP connect.
|
||||
//
|
||||
// The proxy is transparent to the caller. HTTPS sub-resources tunnel
|
||||
// through CONNECT with Chromium performing its own TLS handshake using
|
||||
// the original hostname, preserving SNI and certificate validation.
|
||||
type pinningProxy struct {
|
||||
allowList []*regexp2.Regexp
|
||||
denyList []*regexp2.Regexp
|
||||
|
||||
// decide resolves and validates a URL. Tests may override it.
|
||||
decide func(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time) (gotenberg.OutboundDecision, error)
|
||||
|
||||
// dialPinned dials the pinned IPs for a decision. Tests may override
|
||||
// it to connect to a stub upstream regardless of decision.
|
||||
dialPinned func(ctx context.Context, network string, addrs []netip.Addr, port string) (net.Conn, error)
|
||||
|
||||
// dialBypass dials the destination hostname directly (operator
|
||||
// allow-list opt-in). Tests may override it.
|
||||
dialBypass func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||
|
||||
listener net.Listener
|
||||
server *http.Server
|
||||
wg sync.WaitGroup
|
||||
|
||||
logger *slog.Logger
|
||||
started bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// newPinningProxy returns a pinning proxy configured with the given
|
||||
// allow/deny lists and IP-class policy. The policy bools are applied via
|
||||
// [gotenberg.DecideOutbound] on every request the proxy sees, so
|
||||
// Chromium inherits whatever posture the operator selected. The
|
||||
// returned proxy is not yet listening; call Start.
|
||||
func newPinningProxy(allowList, denyList []*regexp2.Regexp, denyPrivateIPs, denyPublicIPs bool) *pinningProxy {
|
||||
return &pinningProxy{
|
||||
allowList: allowList,
|
||||
denyList: denyList,
|
||||
decide: func(ctx context.Context, rawURL string, allow, deny []*regexp2.Regexp, deadline time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.DecideOutbound(ctx, rawURL, allow, deny, deadline,
|
||||
gotenberg.WithDenyPrivateIPs(denyPrivateIPs),
|
||||
gotenberg.WithDenyPublicIPs(denyPublicIPs),
|
||||
)
|
||||
},
|
||||
dialPinned: gotenberg.DialPinned,
|
||||
dialBypass: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||||
return dialer.DialContext(ctx, network, addr)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Start binds the proxy to 127.0.0.1 on an ephemeral port and serves in a
|
||||
// background goroutine. Bind failures return an error; the caller must
|
||||
// not proceed to start Chromium with --proxy-server.
|
||||
func (p *pinningProxy) Start(logger *slog.Logger) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.started {
|
||||
return errors.New("pinning proxy already started")
|
||||
}
|
||||
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return fmt.Errorf("bind pinning proxy: %w", err)
|
||||
}
|
||||
|
||||
p.listener = l
|
||||
p.logger = logger.With(slog.String("logger", "pinning-proxy"))
|
||||
p.server = &http.Server{
|
||||
Handler: http.HandlerFunc(p.serveHTTP),
|
||||
// Guard against slow header attacks. Body reads are controlled
|
||||
// per-handler.
|
||||
ReadHeaderTimeout: 15 * time.Second,
|
||||
ErrorLog: slog.NewLogLogger(p.logger.Handler(), slog.LevelWarn),
|
||||
}
|
||||
|
||||
p.wg.Go(func() {
|
||||
serveErr := p.server.Serve(l)
|
||||
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
|
||||
p.logger.ErrorContext(context.Background(), fmt.Sprintf("pinning proxy serve: %s", serveErr))
|
||||
}
|
||||
})
|
||||
|
||||
p.started = true
|
||||
p.logger.DebugContext(context.Background(), fmt.Sprintf("pinning proxy listening on %s", l.Addr()))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop shuts the proxy down and waits for in-flight handlers to complete.
|
||||
// Safe to call on a non-started proxy.
|
||||
func (p *pinningProxy) Stop(logger *slog.Logger) error {
|
||||
p.mu.Lock()
|
||||
if !p.started {
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
srv := p.server
|
||||
p.started = false
|
||||
p.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
shutdownErr := srv.Shutdown(ctx)
|
||||
p.wg.Wait()
|
||||
|
||||
if shutdownErr != nil {
|
||||
return fmt.Errorf("shutdown pinning proxy: %w", shutdownErr)
|
||||
}
|
||||
logger.DebugContext(context.Background(), "pinning proxy stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// URL returns the proxy URL suitable for Chromium's --proxy-server flag.
|
||||
// Returns an empty string when the proxy is not listening.
|
||||
func (p *pinningProxy) URL() string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.listener == nil {
|
||||
return ""
|
||||
}
|
||||
return "http://" + p.listener.Addr().String()
|
||||
}
|
||||
|
||||
func (p *pinningProxy) serveHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method == http.MethodConnect {
|
||||
p.handleConnect(w, req)
|
||||
return
|
||||
}
|
||||
p.handleForward(w, req)
|
||||
}
|
||||
|
||||
// handleConnect handles HTTPS (and any other CONNECT) tunnels. Chromium
|
||||
// issues CONNECT host:port; the proxy validates the host, dials the
|
||||
// pinned IP, and splices the client socket with the upstream socket.
|
||||
// Chromium then negotiates TLS end-to-end with the original hostname in
|
||||
// SNI.
|
||||
func (p *pinningProxy) handleConnect(w http.ResponseWriter, req *http.Request) {
|
||||
_, port, err := net.SplitHostPort(req.Host)
|
||||
if err != nil {
|
||||
http.Error(w, "bad CONNECT target", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deadline, ok := req.Context().Deadline()
|
||||
if !ok {
|
||||
deadline = time.Now().Add(30 * time.Second)
|
||||
}
|
||||
|
||||
// The validation URL uses https:// so that http-like scheme checks
|
||||
// apply in [gotenberg.DecideOutbound]. The scheme does not influence
|
||||
// the CONNECT handling beyond filtering.
|
||||
decision, err := p.decide(req.Context(), "https://"+req.Host, p.allowList, p.denyList, deadline)
|
||||
if err != nil {
|
||||
if isClientCancellation(req.Context(), err) {
|
||||
p.logger.DebugContext(req.Context(), fmt.Sprintf("CONNECT abandoned by client for '%s': %s", req.Host, err))
|
||||
} else {
|
||||
p.logger.WarnContext(req.Context(), fmt.Sprintf("CONNECT blocked for '%s': %s", req.Host, err))
|
||||
}
|
||||
http.Error(w, "CONNECT blocked", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var upstream net.Conn
|
||||
switch {
|
||||
case decision.Bypass:
|
||||
upstream, err = p.dialBypass(req.Context(), "tcp", req.Host)
|
||||
case len(decision.Pinned) > 0:
|
||||
upstream, err = p.dialPinned(req.Context(), "tcp", decision.Pinned, port)
|
||||
default:
|
||||
err = errors.New("no pinned addresses and not bypassed")
|
||||
}
|
||||
if err != nil {
|
||||
if isClientCancellation(req.Context(), err) {
|
||||
p.logger.DebugContext(req.Context(), fmt.Sprintf("CONNECT dial abandoned by client for '%s': %s", req.Host, err))
|
||||
} else {
|
||||
p.logger.WarnContext(req.Context(), fmt.Sprintf("CONNECT dial failed for '%s': %s", req.Host, err))
|
||||
}
|
||||
http.Error(w, "upstream dial failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer upstream.Close()
|
||||
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "hijack unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
client, _, err := hj.Hijack()
|
||||
if err != nil {
|
||||
p.logger.ErrorContext(req.Context(), fmt.Sprintf("hijack CONNECT: %s", err))
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
_, err = client.Write([]byte("HTTP/1.1 200 OK\r\n\r\n"))
|
||||
if err != nil {
|
||||
if isClientCancellation(req.Context(), err) {
|
||||
p.logger.DebugContext(req.Context(), fmt.Sprintf("write CONNECT ack abandoned by client: %s", err))
|
||||
} else {
|
||||
p.logger.WarnContext(req.Context(), fmt.Sprintf("write CONNECT ack: %s", err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Splice bytes in both directions until either side closes.
|
||||
var splice sync.WaitGroup
|
||||
splice.Add(2)
|
||||
go func() {
|
||||
defer splice.Done()
|
||||
_, _ = io.Copy(upstream, client)
|
||||
if cw, ok := upstream.(interface{ CloseWrite() error }); ok {
|
||||
_ = cw.CloseWrite()
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer splice.Done()
|
||||
_, _ = io.Copy(client, upstream)
|
||||
if cw, ok := client.(interface{ CloseWrite() error }); ok {
|
||||
_ = cw.CloseWrite()
|
||||
}
|
||||
}()
|
||||
splice.Wait()
|
||||
}
|
||||
|
||||
// handleForward handles plain HTTP requests sent to the proxy as absolute
|
||||
// URIs (GET http://host/path). The proxy revalidates the URL, then
|
||||
// forwards the request via a transport that dials the pinned IP.
|
||||
func (p *pinningProxy) handleForward(w http.ResponseWriter, req *http.Request) {
|
||||
if req.URL == nil || req.URL.Scheme == "" || req.URL.Host == "" {
|
||||
http.Error(w, "absolute URL required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deadline, ok := req.Context().Deadline()
|
||||
if !ok {
|
||||
deadline = time.Now().Add(30 * time.Second)
|
||||
}
|
||||
|
||||
decision, err := p.decide(req.Context(), req.URL.String(), p.allowList, p.denyList, deadline)
|
||||
if err != nil {
|
||||
if isClientCancellation(req.Context(), err) {
|
||||
p.logger.DebugContext(req.Context(), fmt.Sprintf("forward abandoned by client for '%s': %s", req.URL, err))
|
||||
} else {
|
||||
p.logger.WarnContext(req.Context(), fmt.Sprintf("forward blocked for '%s': %s", req.URL, err))
|
||||
}
|
||||
http.Error(w, "request blocked", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
outReq := req.Clone(req.Context())
|
||||
outReq.RequestURI = ""
|
||||
stripHopByHopHeaders(outReq.Header)
|
||||
|
||||
transport := &http.Transport{
|
||||
// Build a fresh transport per request. The decision contains the
|
||||
// pinned IPs to dial; reusing a transport across requests would
|
||||
// leak the decision's closure across unrelated targets.
|
||||
DisableKeepAlives: true,
|
||||
Proxy: nil,
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
_, port, splitErr := net.SplitHostPort(addr)
|
||||
if splitErr != nil {
|
||||
return nil, fmt.Errorf("split forward addr %q: %w", addr, splitErr)
|
||||
}
|
||||
switch {
|
||||
case decision.Bypass:
|
||||
return p.dialBypass(ctx, network, addr)
|
||||
case len(decision.Pinned) > 0:
|
||||
return p.dialPinned(ctx, network, decision.Pinned, port)
|
||||
default:
|
||||
return nil, errors.New("no pinned addresses and not bypassed")
|
||||
}
|
||||
},
|
||||
}
|
||||
defer transport.CloseIdleConnections()
|
||||
|
||||
resp, err := transport.RoundTrip(outReq)
|
||||
if err != nil {
|
||||
if isClientCancellation(req.Context(), err) {
|
||||
p.logger.DebugContext(req.Context(), fmt.Sprintf("forward RoundTrip abandoned by client for '%s': %s", req.URL, err))
|
||||
} else {
|
||||
p.logger.WarnContext(req.Context(), fmt.Sprintf("forward RoundTrip failed for '%s': %s", req.URL, err))
|
||||
}
|
||||
http.Error(w, "upstream error", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
copyHeaders(w.Header(), resp.Header)
|
||||
stripHopByHopHeaders(w.Header())
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
// Per RFC 7230 section 6.1.
|
||||
var hopByHopHeaders = []string{
|
||||
"Connection",
|
||||
"Keep-Alive",
|
||||
"Proxy-Authenticate",
|
||||
"Proxy-Authorization",
|
||||
"Proxy-Connection",
|
||||
"Te",
|
||||
"Trailer",
|
||||
"Transfer-Encoding",
|
||||
"Upgrade",
|
||||
}
|
||||
|
||||
func stripHopByHopHeaders(h http.Header) {
|
||||
for _, name := range hopByHopHeaders {
|
||||
h.Del(name)
|
||||
}
|
||||
}
|
||||
|
||||
func copyHeaders(dst, src http.Header) {
|
||||
for k, vs := range src {
|
||||
for _, v := range vs {
|
||||
dst.Add(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isClientCancellation reports whether err originates from the client (for
|
||||
// example Chromium) closing the connection or letting the request deadline
|
||||
// pass before the proxy could finish validating the destination. Such
|
||||
// errors are not policy refusals: the proxy never reached an allow/deny
|
||||
// rule decision. Callers downgrade these to debug to avoid alarming
|
||||
// operators with noise from speculative or aborted browser requests. The
|
||||
// canonical case is a Chromium DNS prefetch that the browser drops before
|
||||
// the proxy's [outbound.resolveHost] call returns. The [net.DNSError]
|
||||
// returned by [net.Resolver.LookupNetIP] unwraps to [context.Canceled] or
|
||||
// [context.DeadlineExceeded] in that case, so an [errors.Is] walk catches
|
||||
// it.
|
||||
func isClientCancellation(ctx context.Context, err error) bool {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
return ctx.Err() != nil
|
||||
}
|
||||
900
pkg/modules/chromium/pinning_proxy_test.go
Normal file
900
pkg/modules/chromium/pinning_proxy_test.go
Normal file
@@ -0,0 +1,900 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
// recordingHandler is a slog.Handler that captures every record emitted
|
||||
// through it so tests can assert on the level and message of proxy logs.
|
||||
type recordingHandler struct {
|
||||
mu sync.Mutex
|
||||
records []slog.Record
|
||||
}
|
||||
|
||||
func (h *recordingHandler) Enabled(_ context.Context, _ slog.Level) bool { return true }
|
||||
|
||||
func (h *recordingHandler) Handle(_ context.Context, r slog.Record) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.records = append(h.records, r.Clone())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *recordingHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h }
|
||||
func (h *recordingHandler) WithGroup(_ string) slog.Handler { return h }
|
||||
|
||||
func (h *recordingHandler) snapshot() []slog.Record {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
out := make([]slog.Record, len(h.records))
|
||||
copy(out, h.records)
|
||||
return out
|
||||
}
|
||||
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func mustParseURL(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", raw, err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// newRawTCPServer starts a TCP server on 127.0.0.1:0 that calls handle for
|
||||
// every accepted connection. It returns the listener address and a cleanup
|
||||
// function.
|
||||
func newRawTCPServer(t *testing.T, handle func(net.Conn)) (string, func()) {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go handle(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
return l.Addr().String(), func() { _ = l.Close() }
|
||||
}
|
||||
|
||||
// newProxyForTest returns a pinning proxy whose decide and dial functions
|
||||
// are set to test stubs. The proxy is started on a loopback ephemeral
|
||||
// port and stopped during test cleanup.
|
||||
func newProxyForTest(t *testing.T, p *pinningProxy) string {
|
||||
t.Helper()
|
||||
err := p.Start(testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("start pinning proxy: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = p.Stop(testLogger())
|
||||
})
|
||||
return p.URL()
|
||||
}
|
||||
|
||||
func TestPinningProxy_Forward_Pinned_Success(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Host != "example.com" {
|
||||
t.Errorf("upstream expected Host=example.com, got %q", r.Host)
|
||||
}
|
||||
_, _ = fmt.Fprint(w, "hello-from-upstream")
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
upstreamURL := mustParseURL(t, upstream.URL)
|
||||
|
||||
var decideCalls atomic.Int32
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
decideCalls.Add(1)
|
||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||
}
|
||||
p.dialPinned = func(ctx context.Context, network string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||
return net.Dial(network, upstreamURL.Host)
|
||||
}
|
||||
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 resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if string(body) != "hello-from-upstream" {
|
||||
t.Fatalf("body = %q, want %q", body, "hello-from-upstream")
|
||||
}
|
||||
if got := decideCalls.Load(); got != 1 {
|
||||
t.Fatalf("decide called %d times, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinningProxy_Forward_BlockedByDecide(t *testing.T) {
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered)
|
||||
}
|
||||
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||
t.Fatal("dialPinned must not be called when decide returns an error")
|
||||
return nil, errors.New("unreachable")
|
||||
}
|
||||
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://blocked.example/")
|
||||
if err != nil {
|
||||
t.Fatalf("GET via proxy: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinningProxy_Forward_Bypass(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprint(w, "bypassed")
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
upstreamURL := mustParseURL(t, upstream.URL)
|
||||
|
||||
var bypassCalls atomic.Int32
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.OutboundDecision{Bypass: true}, nil
|
||||
}
|
||||
p.dialBypass = func(_ context.Context, network, _ string) (net.Conn, error) {
|
||||
bypassCalls.Add(1)
|
||||
return net.Dial(network, upstreamURL.Host)
|
||||
}
|
||||
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||
t.Fatal("dialPinned must not be called on bypass")
|
||||
return nil, errors.New("unreachable")
|
||||
}
|
||||
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://internal.example/")
|
||||
if err != nil {
|
||||
t.Fatalf("GET via proxy: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if got := bypassCalls.Load(); got != 1 {
|
||||
t.Fatalf("dialBypass called %d times, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinningProxy_Forward_StripsHopByHopHeaders(t *testing.T) {
|
||||
var upstreamSawProxyAuth bool
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Proxy-Authorization") != "" {
|
||||
upstreamSawProxyAuth = true
|
||||
}
|
||||
w.Header().Set("Connection", "close")
|
||||
w.Header().Set("Proxy-Connection", "close")
|
||||
w.Header().Set("X-Downstream", "ok")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
upstreamURL := mustParseURL(t, upstream.URL)
|
||||
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||
}
|
||||
p.dialPinned = func(ctx context.Context, network string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||
return net.Dial(network, upstreamURL.Host)
|
||||
}
|
||||
proxyURL := newProxyForTest(t, p)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("Proxy-Authorization", "Basic Zm9vOmJhcg==")
|
||||
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(mustParseURL(t, proxyURL)),
|
||||
},
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET via proxy: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if upstreamSawProxyAuth {
|
||||
t.Fatalf("upstream received Proxy-Authorization, proxy did not strip it")
|
||||
}
|
||||
if resp.Header.Get("Proxy-Connection") != "" {
|
||||
t.Fatalf("response retained Proxy-Connection, proxy did not strip it")
|
||||
}
|
||||
if resp.Header.Get("X-Downstream") != "ok" {
|
||||
t.Fatalf("response missing X-Downstream header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinningProxy_Forward_RejectsNonAbsoluteURL(t *testing.T) {
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
t.Fatal("decide must not be called for malformed proxy request")
|
||||
return gotenberg.OutboundDecision{}, nil
|
||||
}
|
||||
proxyURL := newProxyForTest(t, p)
|
||||
|
||||
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("dial proxy: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Send a request with a path-only target, not an absolute URI, which
|
||||
// the proxy should reject with 400.
|
||||
_, err = fmt.Fprint(conn, "GET /path HTTP/1.1\r\nHost: example.com\r\n\r\n")
|
||||
if err != nil {
|
||||
t.Fatalf("write request: %v", err)
|
||||
}
|
||||
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("read response: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinningProxy_CONNECT_Pinned_Success(t *testing.T) {
|
||||
upstreamAddr, stop := newRawTCPServer(t, func(c net.Conn) {
|
||||
defer c.Close()
|
||||
_, _ = c.Write([]byte("HI"))
|
||||
buf := make([]byte, 4)
|
||||
n, _ := io.ReadFull(c, buf)
|
||||
_, _ = c.Write(buf[:n])
|
||||
})
|
||||
t.Cleanup(stop)
|
||||
|
||||
var decideCalls atomic.Int32
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
decideCalls.Add(1)
|
||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||
}
|
||||
p.dialPinned = func(_ context.Context, network string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||
return net.Dial(network, upstreamAddr)
|
||||
}
|
||||
proxyURL := newProxyForTest(t, p)
|
||||
|
||||
// Connect to the proxy, send CONNECT, splice raw bytes.
|
||||
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("dial proxy: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
_, err = fmt.Fprintf(conn, "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n")
|
||||
if err != nil {
|
||||
t.Fatalf("write CONNECT: %v", err)
|
||||
}
|
||||
|
||||
br := bufio.NewReader(conn)
|
||||
statusLine, err := br.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("read status: %v", err)
|
||||
}
|
||||
if !strings.Contains(statusLine, " 200 ") {
|
||||
t.Fatalf("CONNECT status = %q, want 200", statusLine)
|
||||
}
|
||||
// Consume the blank line after headers.
|
||||
for {
|
||||
line, err := br.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("read headers: %v", err)
|
||||
}
|
||||
if line == "\r\n" || line == "\n" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
hi := make([]byte, 2)
|
||||
_, err = io.ReadFull(br, hi)
|
||||
if err != nil {
|
||||
t.Fatalf("read greeting: %v", err)
|
||||
}
|
||||
if string(hi) != "HI" {
|
||||
t.Fatalf("greeting = %q, want HI", hi)
|
||||
}
|
||||
|
||||
_, err = conn.Write([]byte("PONG"))
|
||||
if err != nil {
|
||||
t.Fatalf("write PONG: %v", err)
|
||||
}
|
||||
echo := make([]byte, 4)
|
||||
_, err = io.ReadFull(br, echo)
|
||||
if err != nil {
|
||||
t.Fatalf("read echo: %v", err)
|
||||
}
|
||||
if string(echo) != "PONG" {
|
||||
t.Fatalf("echo = %q, want PONG", echo)
|
||||
}
|
||||
if got := decideCalls.Load(); got != 1 {
|
||||
t.Fatalf("decide called %d times, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinningProxy_CONNECT_BlockedByDecide(t *testing.T) {
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered)
|
||||
}
|
||||
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||
t.Fatal("dialPinned must not be called when decide returns an error")
|
||||
return nil, errors.New("unreachable")
|
||||
}
|
||||
proxyURL := newProxyForTest(t, p)
|
||||
|
||||
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("dial proxy: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
_, err = fmt.Fprintf(conn, "CONNECT rebind.example:443 HTTP/1.1\r\nHost: rebind.example:443\r\n\r\n")
|
||||
if err != nil {
|
||||
t.Fatalf("write CONNECT: %v", err)
|
||||
}
|
||||
|
||||
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("read response: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("CONNECT status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPinningProxy_DNSRebind_SingleResolution is the regression test for
|
||||
// the DNS rebinding window. It simulates a DNS authority that returns a
|
||||
// public IP on the first lookup and a loopback IP on subsequent lookups.
|
||||
// The proxy must resolve the host exactly once per request and dial the
|
||||
// IP validated at that moment, so that a second resolution by any later
|
||||
// layer cannot pivot the connection to an internal target.
|
||||
func TestPinningProxy_DNSRebind_SingleResolution(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprint(w, "public-upstream")
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
upstreamURL := mustParseURL(t, upstream.URL)
|
||||
|
||||
var lookupCount atomic.Int32
|
||||
stubDecide := func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
n := lookupCount.Add(1)
|
||||
if n == 1 {
|
||||
// First lookup: returns a public IP, validation passes, the
|
||||
// proxy pins it for the dial.
|
||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("93.184.216.34")}}, nil
|
||||
}
|
||||
// Any subsequent lookup for the same host would return a
|
||||
// loopback IP. This return value must not influence the dial
|
||||
// because the proxy must not call decide again for this request.
|
||||
return gotenberg.OutboundDecision{}, fmt.Errorf("rebind lookup: %w", gotenberg.ErrFiltered)
|
||||
}
|
||||
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p.decide = stubDecide
|
||||
p.dialPinned = func(_ context.Context, network string, addrs []netip.Addr, _ string) (net.Conn, error) {
|
||||
if len(addrs) != 1 || addrs[0].String() != "93.184.216.34" {
|
||||
t.Errorf("dialPinned got addrs %v, want [93.184.216.34]", addrs)
|
||||
}
|
||||
return net.Dial(network, upstreamURL.Host)
|
||||
}
|
||||
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://rebind.example/")
|
||||
if err != nil {
|
||||
t.Fatalf("GET via proxy: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if string(body) != "public-upstream" {
|
||||
t.Fatalf("body = %q, want %q", body, "public-upstream")
|
||||
}
|
||||
if got := lookupCount.Load(); got != 1 {
|
||||
t.Fatalf("decide called %d times, want exactly 1 (rebind protection)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug verifies that
|
||||
// when decide fails because the request context was canceled or its
|
||||
// deadline expired (the canonical case is Chromium dropping a speculative
|
||||
// CONNECT before the proxy finishes resolving the host), the proxy logs
|
||||
// at debug and not at warn. Policy refusals must still warn; see
|
||||
// [TestPinningProxy_CONNECT_BlockedByDecide].
|
||||
func TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug(t *testing.T) {
|
||||
rec := &recordingHandler{}
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
// Mimic the wrap chain produced by outbound.resolveHost when the
|
||||
// DNS lookup is canceled mid-flight by Chromium hanging up.
|
||||
return gotenberg.OutboundDecision{}, fmt.Errorf("validate '%s' host: resolve %q: lookup %s: %w", "https://www.google.com:443", "www.google.com", "www.google.com", context.Canceled)
|
||||
}
|
||||
|
||||
err := p.Start(slog.New(rec))
|
||||
if err != nil {
|
||||
t.Fatalf("start pinning proxy: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
|
||||
proxyURL := p.URL()
|
||||
|
||||
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("dial proxy: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
_, err = fmt.Fprintf(conn, "CONNECT www.google.com:443 HTTP/1.1\r\nHost: www.google.com:443\r\n\r\n")
|
||||
if err != nil {
|
||||
t.Fatalf("write CONNECT: %v", err)
|
||||
}
|
||||
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("read response: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
|
||||
records := rec.snapshot()
|
||||
var found bool
|
||||
for _, r := range records {
|
||||
if !strings.Contains(r.Message, "www.google.com:443") {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if r.Level != slog.LevelDebug {
|
||||
t.Fatalf("record level = %v, want Debug; message: %s", r.Level, r.Message)
|
||||
}
|
||||
if !strings.Contains(r.Message, "abandoned") {
|
||||
t.Fatalf("message = %q, want it to mention abandoned", r.Message)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected a log record mentioning www.google.com:443, found none")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPinningProxy_Forward_ClientCancellation_LoggedAtDebug is the
|
||||
// handleForward equivalent of
|
||||
// [TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug]. Plain
|
||||
// HTTP forward requests aborted by the client must also log at debug.
|
||||
func TestPinningProxy_Forward_ClientCancellation_LoggedAtDebug(t *testing.T) {
|
||||
rec := &recordingHandler{}
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.OutboundDecision{}, fmt.Errorf("validate host: %w", context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
err := p.Start(slog.New(rec))
|
||||
if err != nil {
|
||||
t.Fatalf("start pinning proxy: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
|
||||
proxyURL := p.URL()
|
||||
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{Proxy: http.ProxyURL(mustParseURL(t, proxyURL))},
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
resp, err := client.Get("http://www.google.com/")
|
||||
if err != nil {
|
||||
t.Fatalf("GET via proxy: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
|
||||
records := rec.snapshot()
|
||||
var found bool
|
||||
for _, r := range records {
|
||||
if !strings.Contains(r.Message, "www.google.com") {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if r.Level != slog.LevelDebug {
|
||||
t.Fatalf("record level = %v, want Debug; message: %s", r.Level, r.Message)
|
||||
}
|
||||
if !strings.Contains(r.Message, "abandoned") {
|
||||
t.Fatalf("message = %q, want it to mention abandoned", r.Message)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected a log record mentioning www.google.com, found none")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPinningProxy_PolicyDenial_LoggedAtWarn protects the existing
|
||||
// behavior: a deny-list match (or any non-cancellation decide error) must
|
||||
// still surface at warn level so operators see real refusals.
|
||||
func TestPinningProxy_PolicyDenial_LoggedAtWarn(t *testing.T) {
|
||||
rec := &recordingHandler{}
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.OutboundDecision{}, fmt.Errorf("denied: %w", gotenberg.ErrFiltered)
|
||||
}
|
||||
|
||||
err := p.Start(slog.New(rec))
|
||||
if err != nil {
|
||||
t.Fatalf("start pinning proxy: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
|
||||
proxyURL := p.URL()
|
||||
|
||||
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("dial proxy: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
_, err = fmt.Fprintf(conn, "CONNECT denied.example:443 HTTP/1.1\r\nHost: denied.example:443\r\n\r\n")
|
||||
if err != nil {
|
||||
t.Fatalf("write CONNECT: %v", err)
|
||||
}
|
||||
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("read response: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
records := rec.snapshot()
|
||||
var found bool
|
||||
for _, r := range records {
|
||||
if !strings.Contains(r.Message, "denied.example") {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if r.Level != slog.LevelWarn {
|
||||
t.Fatalf("record level = %v, want Warn; message: %s", r.Level, r.Message)
|
||||
}
|
||||
if !strings.Contains(r.Message, "blocked") {
|
||||
t.Fatalf("message = %q, want it to mention blocked", r.Message)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected a log record mentioning denied.example, found none")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPinningProxy_CONNECT_DialCancellation_LoggedAtDebug verifies that
|
||||
// when the upstream dial fails because the request context was canceled
|
||||
// (typically Chromium dropping a speculative CONNECT before a slow IPv6
|
||||
// dial completes), the proxy logs at debug rather than warn. Genuine
|
||||
// dial failures must still warn; see
|
||||
// [TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn].
|
||||
func TestPinningProxy_CONNECT_DialCancellation_LoggedAtDebug(t *testing.T) {
|
||||
rec := &recordingHandler{}
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||
}
|
||||
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||
// Mimic a dial canceled mid-flight by the client hanging up,
|
||||
// which is what net.Dialer returns when ctx.Err() is Canceled.
|
||||
return nil, fmt.Errorf("dial tcp [2001:4860:482b:7700::]:443: %w", context.Canceled)
|
||||
}
|
||||
|
||||
err := p.Start(slog.New(rec))
|
||||
if err != nil {
|
||||
t.Fatalf("start pinning proxy: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
|
||||
proxyURL := p.URL()
|
||||
|
||||
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("dial proxy: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
_, err = fmt.Fprintf(conn, "CONNECT www.google.com:443 HTTP/1.1\r\nHost: www.google.com:443\r\n\r\n")
|
||||
if err != nil {
|
||||
t.Fatalf("write CONNECT: %v", err)
|
||||
}
|
||||
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("read response: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, want 502", resp.StatusCode)
|
||||
}
|
||||
|
||||
records := rec.snapshot()
|
||||
var found bool
|
||||
for _, r := range records {
|
||||
if !strings.Contains(r.Message, "CONNECT dial") || !strings.Contains(r.Message, "www.google.com:443") {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if r.Level != slog.LevelDebug {
|
||||
t.Fatalf("record level = %v, want Debug; message: %s", r.Level, r.Message)
|
||||
}
|
||||
if !strings.Contains(r.Message, "abandoned") {
|
||||
t.Fatalf("message = %q, want it to mention abandoned", r.Message)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected a log record mentioning CONNECT dial for www.google.com:443, found none")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn guards the existing
|
||||
// behavior: a genuine dial failure (host unreachable, refused, etc.)
|
||||
// must still warn so operators see real problems.
|
||||
func TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn(t *testing.T) {
|
||||
rec := &recordingHandler{}
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||
}
|
||||
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||
return nil, errors.New("connection refused")
|
||||
}
|
||||
|
||||
err := p.Start(slog.New(rec))
|
||||
if err != nil {
|
||||
t.Fatalf("start pinning proxy: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
|
||||
proxyURL := p.URL()
|
||||
|
||||
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("dial proxy: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
_, err = fmt.Fprintf(conn, "CONNECT real.example:443 HTTP/1.1\r\nHost: real.example:443\r\n\r\n")
|
||||
if err != nil {
|
||||
t.Fatalf("write CONNECT: %v", err)
|
||||
}
|
||||
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("read response: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
records := rec.snapshot()
|
||||
var found bool
|
||||
for _, r := range records {
|
||||
if !strings.Contains(r.Message, "CONNECT dial") || !strings.Contains(r.Message, "real.example") {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if r.Level != slog.LevelWarn {
|
||||
t.Fatalf("record level = %v, want Warn; message: %s", r.Level, r.Message)
|
||||
}
|
||||
if !strings.Contains(r.Message, "failed") {
|
||||
t.Fatalf("message = %q, want it to mention failed", r.Message)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected a log record mentioning CONNECT dial for real.example, found none")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPinningProxy_Forward_RoundTripCancellation_LoggedAtDebug verifies
|
||||
// the handleForward dial-cancellation path: when the inner Transport's
|
||||
// dial returns a canceled error (client hung up mid-dial), the proxy
|
||||
// logs at debug, not warn. Genuine RoundTrip failures still warn.
|
||||
func TestPinningProxy_Forward_RoundTripCancellation_LoggedAtDebug(t *testing.T) {
|
||||
rec := &recordingHandler{}
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||
}
|
||||
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||
return nil, fmt.Errorf("dial tcp [2001:4860::]:80: %w", context.Canceled)
|
||||
}
|
||||
|
||||
err := p.Start(slog.New(rec))
|
||||
if err != nil {
|
||||
t.Fatalf("start pinning proxy: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
|
||||
proxyURL := p.URL()
|
||||
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{Proxy: http.ProxyURL(mustParseURL(t, proxyURL))},
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
resp, err := client.Get("http://www.google.com/")
|
||||
if err != nil {
|
||||
t.Fatalf("GET via proxy: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, want 502", resp.StatusCode)
|
||||
}
|
||||
|
||||
records := rec.snapshot()
|
||||
var found bool
|
||||
for _, r := range records {
|
||||
if !strings.Contains(r.Message, "forward RoundTrip") || !strings.Contains(r.Message, "www.google.com") {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if r.Level != slog.LevelDebug {
|
||||
t.Fatalf("record level = %v, want Debug; message: %s", r.Level, r.Message)
|
||||
}
|
||||
if !strings.Contains(r.Message, "abandoned") {
|
||||
t.Fatalf("message = %q, want it to mention abandoned", r.Message)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected a log record mentioning forward RoundTrip for www.google.com, found none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsClientCancellation(t *testing.T) {
|
||||
canceledCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "wrapped context.Canceled",
|
||||
ctx: context.Background(),
|
||||
err: fmt.Errorf("validate host: %w", context.Canceled),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wrapped context.DeadlineExceeded",
|
||||
ctx: context.Background(),
|
||||
err: fmt.Errorf("validate host: %w", context.DeadlineExceeded),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "request context already done",
|
||||
ctx: canceledCtx,
|
||||
err: errors.New("some unrelated error"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "policy denial",
|
||||
ctx: context.Background(),
|
||||
err: fmt.Errorf("denied: %w", gotenberg.ErrFiltered),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "plain dial failure",
|
||||
ctx: context.Background(),
|
||||
err: errors.New("connection refused"),
|
||||
want: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := isClientCancellation(tc.ctx, tc.err)
|
||||
if got != tc.want {
|
||||
t.Fatalf("isClientCancellation = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinningProxy_StartTwice(t *testing.T) {
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
err := p.Start(testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("first Start: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = p.Stop(testLogger()) })
|
||||
|
||||
err = p.Start(testLogger())
|
||||
if err == nil {
|
||||
t.Fatal("second Start: expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinningProxy_StopIdempotent(t *testing.T) {
|
||||
p := newPinningProxy(nil, nil, false, false)
|
||||
// Stop on a never-started proxy is a no-op.
|
||||
if err := p.Stop(testLogger()); err != nil {
|
||||
t.Fatalf("Stop on never-started proxy: %v", err)
|
||||
}
|
||||
|
||||
if err := p.Start(testLogger()); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if err := p.Stop(testLogger()); err != nil {
|
||||
t.Fatalf("first Stop: %v", err)
|
||||
}
|
||||
if err := p.Stop(testLogger()); err != nil {
|
||||
t.Fatalf("second Stop on stopped proxy: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -17,7 +18,6 @@ import (
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"go.uber.org/multierr"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||
@@ -150,7 +150,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
|
||||
for i, cookie := range cookies {
|
||||
if strings.TrimSpace(cookie.Name) == "" || strings.TrimSpace(cookie.Value) == "" || strings.TrimSpace(cookie.Domain) == "" {
|
||||
err = multierr.Append(err, fmt.Errorf("cookie %d must have its name, value and domain set", i))
|
||||
err = errors.Join(err, fmt.Errorf("cookie %d must have its name, value and domain set", i))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,14 +183,12 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
if len(parts) == 2 && strings.ToLower(parts[0]) == "scope" && parts[1] != "" {
|
||||
scope = parts[1]
|
||||
} else {
|
||||
err = multierr.Append(err, fmt.Errorf("invalid scope '%s' for header '%s'", scope, k))
|
||||
err = errors.Join(err, fmt.Errorf("invalid scope '%s' for header '%s'", scope, k))
|
||||
invalidScopeToken = true
|
||||
break
|
||||
}
|
||||
} else {
|
||||
if token != "" {
|
||||
valueTokens = append(valueTokens, token)
|
||||
}
|
||||
} else if token != "" {
|
||||
valueTokens = append(valueTokens, token)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,9 +200,10 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
if len(scope) > 0 {
|
||||
p, errCompile := regexp2.Compile(scope, regexp2.None)
|
||||
if errCompile != nil {
|
||||
err = multierr.Append(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
|
||||
}
|
||||
p.MatchTimeout = 5 * time.Second
|
||||
scopeRegexp = p
|
||||
}
|
||||
|
||||
@@ -342,11 +341,12 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
||||
defaultScreenshotOptions := DefaultScreenshotOptions()
|
||||
|
||||
var (
|
||||
width, height int
|
||||
clip bool
|
||||
format string
|
||||
quality int
|
||||
optimizeForSpeed bool
|
||||
width, height int
|
||||
clip bool
|
||||
format string
|
||||
quality int
|
||||
optimizeForSpeed bool
|
||||
deviceScaleFactor float64
|
||||
)
|
||||
|
||||
form.
|
||||
@@ -389,21 +389,51 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
||||
quality = intValue
|
||||
return nil
|
||||
}).
|
||||
Bool("optimizeForSpeed", &optimizeForSpeed, defaultScreenshotOptions.OptimizeForSpeed)
|
||||
Bool("optimizeForSpeed", &optimizeForSpeed, defaultScreenshotOptions.OptimizeForSpeed).
|
||||
Float64("deviceScaleFactor", &deviceScaleFactor, defaultScreenshotOptions.DeviceScaleFactor)
|
||||
|
||||
screenshotOptions := ScreenshotOptions{
|
||||
Options: options,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Clip: clip,
|
||||
Format: format,
|
||||
Quality: quality,
|
||||
OptimizeForSpeed: optimizeForSpeed,
|
||||
Options: options,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Clip: clip,
|
||||
Format: format,
|
||||
Quality: quality,
|
||||
OptimizeForSpeed: optimizeForSpeed,
|
||||
DeviceScaleFactor: deviceScaleFactor,
|
||||
}
|
||||
|
||||
return form, screenshotOptions
|
||||
}
|
||||
|
||||
// rejectFileScheme returns an HTTP 400 [api] error when rawURL uses the
|
||||
// file:// scheme. /forms/chromium/convert/url and
|
||||
// /forms/chromium/screenshot/url accept user-supplied URLs and are
|
||||
// intended for navigating to remote HTTP(S) resources; allowing file://
|
||||
// lets a caller reach Chromium's working directory through the default
|
||||
// deny-list's /tmp/ allowance, which exists only to serve main-page
|
||||
// HTML/Markdown that the other routes generate. Filter the scheme at the
|
||||
// route layer where no request-scoped allowedFilePrefixes exists.
|
||||
func rejectFileScheme(rawURL string) error {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("parse URL: %w", err),
|
||||
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid URL: %s", err)),
|
||||
)
|
||||
}
|
||||
if strings.EqualFold(parsed.Scheme, "file") {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("file:// scheme not allowed on URL route"),
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusBadRequest,
|
||||
"file:// URLs are not accepted on this route. Use the /convert/html or /convert/markdown routes to render local HTML",
|
||||
),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// convertUrlRoute returns an [api.Route] which can convert a URL to PDF.
|
||||
func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
return api.Route{
|
||||
@@ -417,7 +447,13 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
||||
attachmentsPaths := pdfengines.FormDataPdfAttachments(form)
|
||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
||||
stamp := pdfengines.FormDataPdfStamp(form, false)
|
||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||
|
||||
var url string
|
||||
err := form.
|
||||
@@ -427,7 +463,21 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, attachmentsPaths)
|
||||
err = rejectFileScheme(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reject URL scheme: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate watermark: %w", err)
|
||||
}
|
||||
err = pdfengines.EnsureStampFile(&stamp, stampFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate stamp: %w", err)
|
||||
}
|
||||
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert URL to PDF: %w", err)
|
||||
}
|
||||
@@ -456,6 +506,11 @@ func screenshotUrlRoute(chromium Api) api.Route {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
|
||||
err = rejectFileScheme(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reject URL scheme: %w", err)
|
||||
}
|
||||
|
||||
err = screenshotUrl(ctx, chromium, url, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("URL screenshot: %w", err)
|
||||
@@ -480,7 +535,13 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
||||
attachmentsPaths := pdfengines.FormDataPdfAttachments(form)
|
||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
||||
stamp := pdfengines.FormDataPdfStamp(form, false)
|
||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||
|
||||
var inputPath string
|
||||
err := form.
|
||||
@@ -490,8 +551,18 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate watermark: %w", err)
|
||||
}
|
||||
err = pdfengines.EnsureStampFile(&stamp, stampFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate stamp: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("file://%s", inputPath)
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, attachmentsPaths)
|
||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert HTML to PDF: %w", err)
|
||||
}
|
||||
@@ -521,6 +592,7 @@ func screenshotHtmlRoute(chromium Api) api.Route {
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("file://%s", inputPath)
|
||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
||||
err = screenshotUrl(ctx, chromium, url, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("HTML screenshot: %w", err)
|
||||
@@ -545,7 +617,13 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
||||
attachmentsPaths := pdfengines.FormDataPdfAttachments(form)
|
||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
||||
stamp := pdfengines.FormDataPdfStamp(form, false)
|
||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||
|
||||
var (
|
||||
inputPath string
|
||||
@@ -560,12 +638,22 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate watermark: %w", err)
|
||||
}
|
||||
err = pdfengines.EnsureStampFile(&stamp, stampFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate stamp: %w", err)
|
||||
}
|
||||
|
||||
url, err := markdownToHtml(ctx, inputPath, markdownPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
|
||||
}
|
||||
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, attachmentsPaths)
|
||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert markdown to PDF: %w", err)
|
||||
}
|
||||
@@ -604,6 +692,7 @@ func screenshotMarkdownRoute(chromium Api) api.Route {
|
||||
return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
|
||||
}
|
||||
|
||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
||||
err = screenshotUrl(ctx, chromium, url, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("markdown screenshot: %w", err)
|
||||
@@ -629,7 +718,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
|
||||
var path string
|
||||
|
||||
for _, markdownPath := range markdownPaths {
|
||||
markdownFilename := filepath.Base(markdownPath)
|
||||
markdownFilename := ctx.OriginalFilename(markdownPath)
|
||||
|
||||
if filename == markdownFilename {
|
||||
path = markdownPath
|
||||
@@ -638,7 +727,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
markdownFilesNotFoundErr = multierr.Append(
|
||||
markdownFilesNotFoundErr = errors.Join(
|
||||
markdownFilesNotFoundErr,
|
||||
fmt.Errorf("'%s'", filename),
|
||||
)
|
||||
@@ -689,7 +778,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
|
||||
return fmt.Sprintf("file://%s", inputPath), nil
|
||||
}
|
||||
|
||||
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, attachmentsPaths []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, userPassword, ownerPassword string, embedPaths []string, embedsMetadata map[string]map[string]string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error {
|
||||
outputPath := ctx.GeneratePath(".pdf")
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1130.
|
||||
filename := ctx.OutputFilename(outputPath)
|
||||
@@ -751,26 +840,53 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
||||
return fmt.Errorf("convert to PDF: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outputPaths, err := pdfengines.SplitPdfStub(ctx, engine, mode, []string{outputPath})
|
||||
if err != nil {
|
||||
return fmt.Errorf("split PDF: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.WatermarkStub(ctx, engine, watermark, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("watermark PDFs: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.StampStub(ctx, engine, stamp, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stamp PDFs: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.RotateStub(ctx, engine, rotateAngle, rotatePages, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rotate PDFs: %w", err)
|
||||
}
|
||||
|
||||
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert PDF(s): %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.AddAttachmentsStub(ctx, engine, attachmentsPaths, convertOutputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add attachments into PDFs: %w", err)
|
||||
}
|
||||
|
||||
// Metadata, embeds are written after Convert, as LibreOffice
|
||||
// strips them during PDF/A conversion.
|
||||
err = pdfengines.WriteMetadataStub(ctx, engine, metadata, convertOutputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write metadata: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.EmbedFilesStub(ctx, engine, embedPaths, convertOutputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("embed files into PDFs: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.EmbedFilesMetadataStub(ctx, engine, embedsMetadata, convertOutputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set embeds metadata: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||
|
||||
@@ -31,7 +31,9 @@ func printToPdfActionFunc(logger *slog.Logger, outputPath string, options PdfOpt
|
||||
|
||||
// There are 96 CSS pixels per inch.
|
||||
// See https://issues.chromium.org/issues/40267771#comment14.
|
||||
paperHeight = cssContentSize.Height / 96
|
||||
// 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.
|
||||
}
|
||||
|
||||
@@ -162,11 +164,11 @@ func captureScreenshotActionFunc(logger *slog.Logger, outputPath string, options
|
||||
}
|
||||
}
|
||||
|
||||
func setDeviceMetricsOverride(logger *slog.Logger, width, height int) chromedp.ActionFunc {
|
||||
func setDeviceMetricsOverride(logger *slog.Logger, width, height int, deviceScaleFactor float64) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
logger.DebugContext(ctx, "set device metrics override")
|
||||
|
||||
err := emulation.SetDeviceMetricsOverride(int64(width), int64(height), 1.0, false).Do(ctx)
|
||||
err := emulation.SetDeviceMetricsOverride(int64(width), int64(height), deviceScaleFactor, false).Do(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -303,38 +305,40 @@ func userAgentOverride(logger *slog.Logger, userAgent string) chromedp.ActionFun
|
||||
// network.SetExtraHTTPHeaders set the headers for ALL requests from the page.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1011.
|
||||
//
|
||||
//func extraHttpHeadersActionFunc(logger *slog.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
|
||||
// return func(ctx context.Context) error {
|
||||
// if len(extraHttpHeaders) == 0 {
|
||||
// logger.DebugContext(ctx, "no extra HTTP headers")
|
||||
// return nil
|
||||
// }
|
||||
// func extraHttpHeadersActionFunc(logger *slog.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
|
||||
// return func(ctx context.Context) error {
|
||||
// if len(extraHttpHeaders) == 0 {
|
||||
// logger.DebugContext(ctx,"no extra HTTP headers")
|
||||
// return nil
|
||||
// }
|
||||
//
|
||||
// logger.DebugContext(ctx, fmt.Sprintf("extra HTTP headers: %+v", extraHttpHeaders))
|
||||
// logger.DebugContext(ctx,fmt.Sprintf("extra HTTP headers: %+v", extraHttpHeaders))
|
||||
//
|
||||
// headers := make(network.Headers, len(extraHttpHeaders))
|
||||
// for key, value := range extraHttpHeaders {
|
||||
// headers[key] = value
|
||||
// }
|
||||
// headers := make(network.Headers, len(extraHttpHeaders))
|
||||
// for key, value := range extraHttpHeaders {
|
||||
// headers[key] = value
|
||||
// }
|
||||
//
|
||||
// err := network.SetExtraHTTPHeaders(headers).Do(ctx)
|
||||
// if err == nil {
|
||||
// return nil
|
||||
// }
|
||||
// err := network.SetExtraHTTPHeaders(headers).Do(ctx)
|
||||
// if err == nil {
|
||||
// return nil
|
||||
// }
|
||||
//
|
||||
// return fmt.Errorf("set extra HTTP headers: %w", err)
|
||||
// }
|
||||
//}
|
||||
// return fmt.Errorf("set extra HTTP headers: %w", err)
|
||||
// }
|
||||
// }
|
||||
|
||||
func navigateActionFunc(logger *slog.Logger, url string, skipNetworkIdleEvent, skipNetworkAlmostIdleEvent bool) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
logger.DebugContext(ctx, fmt.Sprintf("navigate to '%s'", url))
|
||||
|
||||
_, _, _, _, err := page.Navigate(url).Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("navigate to '%s': %w", url, err)
|
||||
}
|
||||
|
||||
// Register lifecycle listeners before issuing Page.navigate. For
|
||||
// fast loads (typically file:// pages with no external
|
||||
// sub-resources), DomContentEventFired / LoadEventFired /
|
||||
// LoadingFinished can fire between Navigate.Do returning and
|
||||
// runBatch spawning the waiter goroutines. Registering ahead of
|
||||
// the navigate command closes that race.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1561.
|
||||
waitFunc := []func() error{
|
||||
waitForEventDomContentEventFired(ctx, logger),
|
||||
waitForEventLoadEventFired(ctx, logger),
|
||||
@@ -353,6 +357,11 @@ func navigateActionFunc(logger *slog.Logger, url string, skipNetworkIdleEvent, s
|
||||
logger.DebugContext(ctx, "skipping network almost idle event")
|
||||
}
|
||||
|
||||
_, _, _, _, err := page.Navigate(url).Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("navigate to '%s': %w", url, err)
|
||||
}
|
||||
|
||||
err = runBatch(
|
||||
ctx,
|
||||
waitFunc...,
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
// Package exiftool provides an implementation of the gotenberg.PdfEngine
|
||||
// interface using the ExifTool command-line tool. This package allows for:
|
||||
// Package exiftool implements gotenberg.PdfEngine using the ExifTool command-line tool. Reads and writes PDF metadata.
|
||||
//
|
||||
// 1. The reading of metadata.
|
||||
// 2. The writing of metadata.
|
||||
// Requires the EXIFTOOL_BIN_PATH environment variable.
|
||||
//
|
||||
// The path to the exiftool binary must be specified using the
|
||||
// EXIFTOOL_BIN_PATH environment variable.
|
||||
//
|
||||
// See: https://exiftool.org.
|
||||
// See https://exiftool.org.
|
||||
package exiftool
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user