mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-12 18:32:14 +01:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb472124c4 | ||
|
|
f584fa2dc0 | ||
|
|
81d43cfbad | ||
|
|
fe72902cdb | ||
|
|
8898bd2b4a | ||
|
|
a62abef215 | ||
|
|
cf9fd7eedc | ||
|
|
7af3cd1ff5 | ||
|
|
4296474889 | ||
|
|
bfde451e4e | ||
|
|
fd8c5441d8 | ||
|
|
d7421748d0 |
@@ -1,76 +0,0 @@
|
|||||||
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"}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
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"}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
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"}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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"}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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"}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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"}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Debug
|
|
||||||
type: http
|
|
||||||
seq: 3
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{baseUrl}}/debug
|
|
||||||
body: none
|
|
||||||
auth: none
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Health
|
|
||||||
type: http
|
|
||||||
seq: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{baseUrl}}/health
|
|
||||||
body: none
|
|
||||||
auth: none
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Prometheus Metrics
|
|
||||||
type: http
|
|
||||||
seq: 4
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{baseUrl}}/prometheus/metrics
|
|
||||||
body: none
|
|
||||||
auth: none
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Version
|
|
||||||
type: http
|
|
||||||
seq: 2
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{baseUrl}}/version
|
|
||||||
body: none
|
|
||||||
auth: none
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
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"}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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"}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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"}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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"}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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"}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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"}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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"}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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"}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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"}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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"}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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"}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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"}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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"}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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"}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# 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/`).
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "1",
|
|
||||||
"name": "Gotenberg",
|
|
||||||
"type": "collection",
|
|
||||||
"ignore": ["node_modules", ".git"]
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
headers {
|
|
||||||
Gotenberg-Trace: {{traceId}}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
vars {
|
|
||||||
baseUrl: https://demo.gotenberg.dev
|
|
||||||
traceId: bruno-demo
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
vars {
|
|
||||||
baseUrl: http://localhost:3000
|
|
||||||
traceId: bruno-local
|
|
||||||
}
|
|
||||||
3
.env
3
.env
@@ -2,5 +2,6 @@ GOTENBERG_VERSION=snapshot
|
|||||||
DOCKER_REGISTRY=gotenberg
|
DOCKER_REGISTRY=gotenberg
|
||||||
DOCKER_REPOSITORY=gotenberg
|
DOCKER_REPOSITORY=gotenberg
|
||||||
DOCKERFILE=build/Dockerfile
|
DOCKERFILE=build/Dockerfile
|
||||||
|
DOCKERFILE_CLOUDRUN=build/Dockerfile.cloudrun
|
||||||
|
DOCKERFILE_AWS_LAMBDA=build/Dockerfile.aws-lambda
|
||||||
DOCKER_BUILD_CONTEXT='.'
|
DOCKER_BUILD_CONTEXT='.'
|
||||||
TARGET=gotenberg
|
|
||||||
|
|||||||
65
.github/actions/build-test-push/action.yml
vendored
65
.github/actions/build-test-push/action.yml
vendored
@@ -29,32 +29,14 @@ inputs:
|
|||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
tags:
|
tags:
|
||||||
description: Comma separated list of full variant tags
|
description: Comma separated list of tag
|
||||||
value: ${{ steps.build.outputs.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:
|
tags_cloud_run:
|
||||||
description: Comma separated list of Cloud Run full variant tags (linux/amd64 only)
|
description: Comma separated list of Cloud Run tags (linux/amd64 only)
|
||||||
value: ${{ steps.build.outputs.tags_cloud_run }}
|
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:
|
tags_aws_lambda:
|
||||||
description: Comma separated list of AWS Lambda full variant tags (linux/amd64 and linux/arm64 only)
|
description: Comma separated list of AWS Lambda tags (linux/amd64 and linux/arm64 only)
|
||||||
value: ${{ steps.build.outputs.tags_aws_lambda }}
|
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:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
@@ -66,7 +48,7 @@ runs:
|
|||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
if: inputs.docker_hub_username != ''
|
if: inputs.docker_hub_username != ''
|
||||||
@@ -78,53 +60,34 @@ runs:
|
|||||||
- name: Build ${{ inputs.platform }}
|
- name: Build ${{ inputs.platform }}
|
||||||
id: build
|
id: build
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
|
||||||
INPUT_VERSION: ${{ inputs.version }}
|
|
||||||
INPUT_PLATFORM: ${{ inputs.platform }}
|
|
||||||
INPUT_ALTERNATE_REPOSITORY: ${{ inputs.alternate_repository }}
|
|
||||||
INPUT_DRY_RUN: ${{ inputs.dry_run }}
|
|
||||||
run: |
|
run: |
|
||||||
.github/actions/build-test-push/build.sh \
|
.github/actions/build-test-push/build.sh \
|
||||||
--version "$INPUT_VERSION" \
|
--version "${{ inputs.version }}" \
|
||||||
--platform "$INPUT_PLATFORM" \
|
--platform "${{ inputs.platform }}" \
|
||||||
--alternate-repository "$INPUT_ALTERNATE_REPOSITORY" \
|
--alternate-repository "${{ inputs.alternate_repository }}" \
|
||||||
--dry-run "$INPUT_DRY_RUN"
|
--dry-run "${{ inputs.dry_run }}"
|
||||||
|
|
||||||
- name: Run integration tests
|
- name: Run integration tests
|
||||||
if: inputs.skip_integrations_tests != 'true'
|
if: inputs.skip_integrations_tests != 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
|
||||||
INPUT_VERSION: ${{ inputs.version }}
|
|
||||||
INPUT_PLATFORM: ${{ inputs.platform }}
|
|
||||||
INPUT_ALTERNATE_REPOSITORY: ${{ inputs.alternate_repository }}
|
|
||||||
INPUT_DRY_RUN: ${{ inputs.dry_run }}
|
|
||||||
run: |
|
run: |
|
||||||
.github/actions/build-test-push/test.sh \
|
.github/actions/build-test-push/test.sh \
|
||||||
--version "$INPUT_VERSION" \
|
--version "${{ inputs.version }}" \
|
||||||
--platform "$INPUT_PLATFORM" \
|
--platform "${{ inputs.platform }}" \
|
||||||
--alternate-repository "$INPUT_ALTERNATE_REPOSITORY" \
|
--alternate-repository "${{ inputs.alternate_repository }}" \
|
||||||
--dry-run "$INPUT_DRY_RUN"
|
--dry-run "${{ inputs.dry_run }}"
|
||||||
|
|
||||||
- name: Push
|
- name: Push
|
||||||
if: inputs.docker_hub_username != ''
|
if: inputs.docker_hub_username != ''
|
||||||
shell: bash
|
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: |
|
run: |
|
||||||
.github/actions/build-test-push/push.sh \
|
.github/actions/build-test-push/push.sh \
|
||||||
--tags "$INPUT_TAGS" \
|
--tags "${{ steps.build.outputs.tags }},${{ steps.build.outputs.tags_cloud_run }},${{ steps.build.outputs.tags_aws_lambda }}" \
|
||||||
--dry-run "$INPUT_DRY_RUN"
|
--dry-run "${{ inputs.dry_run }}"
|
||||||
|
|
||||||
- name: Outputs
|
- name: Outputs
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "tags=${{ steps.build.outputs.tags }}"
|
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=${{ 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=${{ 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,14 +55,8 @@ fi
|
|||||||
|
|
||||||
# Build tags arrays.
|
# Build tags arrays.
|
||||||
tags=()
|
tags=()
|
||||||
tags_chromium=()
|
|
||||||
tags_libreoffice=()
|
|
||||||
tags_cloud_run=()
|
tags_cloud_run=()
|
||||||
tags_cloud_run_chromium=()
|
|
||||||
tags_cloud_run_libreoffice=()
|
|
||||||
tags_aws_lambda=()
|
tags_aws_lambda=()
|
||||||
tags_aws_lambda_chromium=()
|
|
||||||
tags_aws_lambda_libreoffice=()
|
|
||||||
|
|
||||||
IFS='/' read -ra arch <<< "$platform"
|
IFS='/' read -ra arch <<< "$platform"
|
||||||
IFS='.' read -ra semver <<< "$version"
|
IFS='.' read -ra semver <<< "$version"
|
||||||
@@ -75,95 +69,55 @@ if [ "${#semver[@]}" -eq 3 ]; then
|
|||||||
minor="${semver[1]}"
|
minor="${semver[1]}"
|
||||||
patch="${semver[2]}"
|
patch="${semver[2]}"
|
||||||
|
|
||||||
for suffix in "latest" "$major" "$major.$minor" "$major.$minor.$patch"; do
|
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-${arch[1]}")
|
||||||
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-${arch[1]}")
|
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major-${arch[1]}")
|
||||||
tags_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-chromium-${arch[1]}")
|
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor-${arch[1]}")
|
||||||
tags_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-libreoffice-${arch[1]}")
|
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor.$patch-${arch[1]}")
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$platform" = "linux/amd64" ]; then
|
if [ "$platform" = "linux/amd64" ]; then
|
||||||
for suffix in "latest" "$major" "$major.$minor" "$major.$minor.$patch"; do
|
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-cloudrun")
|
||||||
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-cloudrun")
|
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major-cloudrun")
|
||||||
tags_cloud_run_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-chromium-cloudrun")
|
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor-cloudrun")
|
||||||
tags_cloud_run_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-libreoffice-cloudrun")
|
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor.$patch-cloudrun")
|
||||||
done
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then
|
if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then
|
||||||
for suffix in "latest" "$major" "$major.$minor" "$major.$minor.$patch"; do
|
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-aws-lambda-${arch[1]}")
|
||||||
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-aws-lambda-${arch[1]}")
|
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major-aws-lambda-${arch[1]}")
|
||||||
tags_aws_lambda_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-chromium-aws-lambda-${arch[1]}")
|
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor-aws-lambda-${arch[1]}")
|
||||||
tags_aws_lambda_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-libreoffice-aws-lambda-${arch[1]}")
|
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor.$patch-aws-lambda-${arch[1]}")
|
||||||
done
|
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo
|
echo
|
||||||
echo "Non-semver version detected, fallback to $version"
|
echo "Non-semver version detected, fallback to $version"
|
||||||
|
|
||||||
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-${arch[1]}")
|
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
|
if [ "$platform" = "linux/amd64" ]; then
|
||||||
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-cloudrun")
|
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
|
fi
|
||||||
|
|
||||||
if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then
|
if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then
|
||||||
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-aws-lambda-${arch[1]}")
|
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
|
||||||
fi
|
fi
|
||||||
|
|
||||||
tags_flags=()
|
tags_flags=()
|
||||||
tags_chromium_flags=()
|
|
||||||
tags_libreoffice_flags=()
|
|
||||||
tags_cloud_run_flags=()
|
tags_cloud_run_flags=()
|
||||||
tags_cloud_run_chromium_flags=()
|
|
||||||
tags_cloud_run_libreoffice_flags=()
|
|
||||||
tags_aws_lambda_flags=()
|
tags_aws_lambda_flags=()
|
||||||
tags_aws_lambda_chromium_flags=()
|
|
||||||
tags_aws_lambda_libreoffice_flags=()
|
|
||||||
|
|
||||||
echo "Will use the following tags:"
|
echo "Will use the following tags:"
|
||||||
for tag in "${tags[@]}"; do
|
for tag in "${tags[@]}"; do
|
||||||
tags_flags+=("-t" "$tag")
|
tags_flags+=("-t" "$tag")
|
||||||
echo "- $tag"
|
echo "- $tag"
|
||||||
done
|
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
|
for tag in "${tags_cloud_run[@]}"; do
|
||||||
tags_cloud_run_flags+=("-t" "$tag")
|
tags_cloud_run_flags+=("-t" "$tag")
|
||||||
echo "- $tag"
|
echo "- $tag"
|
||||||
done
|
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
|
for tag in "${tags_aws_lambda[@]}"; do
|
||||||
tags_aws_lambda_flags+=("-t" "$tag")
|
tags_aws_lambda_flags+=("-t" "$tag")
|
||||||
echo "- $tag"
|
echo "- $tag"
|
||||||
done
|
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
|
echo
|
||||||
|
|
||||||
# Build images.
|
# Build images.
|
||||||
@@ -191,9 +145,7 @@ join() {
|
|||||||
|
|
||||||
no_arch_tag="$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version"
|
no_arch_tag="$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version"
|
||||||
|
|
||||||
# Full variant.
|
|
||||||
cmd="docker buildx build \
|
cmd="docker buildx build \
|
||||||
--target gotenberg \
|
|
||||||
--build-arg GOTENBERG_VERSION=$version \
|
--build-arg GOTENBERG_VERSION=$version \
|
||||||
--platform $platform \
|
--platform $platform \
|
||||||
--load \
|
--load \
|
||||||
@@ -203,102 +155,30 @@ cmd="docker buildx build \
|
|||||||
"
|
"
|
||||||
run_cmd "$cmd"
|
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
|
if [ "$platform" = "linux/amd64" ]; then
|
||||||
cmd="docker buildx build \
|
cmd="docker build \
|
||||||
--target gotenberg-cloudrun \
|
--build-arg DOCKER_REGISTRY=$DOCKER_REGISTRY \
|
||||||
|
--build-arg DOCKER_REPOSITORY=$DOCKER_REPOSITORY \
|
||||||
--build-arg GOTENBERG_VERSION=$version \
|
--build-arg GOTENBERG_VERSION=$version \
|
||||||
--platform $platform \
|
|
||||||
--load \
|
|
||||||
${tags_cloud_run_flags[*]} \
|
${tags_cloud_run_flags[*]} \
|
||||||
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
|
-f $DOCKERFILE_CLOUDRUN $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"
|
run_cmd "$cmd"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# AWS Lambda variants (amd64 + arm64 only).
|
|
||||||
if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then
|
if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then
|
||||||
cmd="docker buildx build \
|
cmd="docker build \
|
||||||
--target gotenberg-aws-lambda \
|
--build-arg DOCKER_REGISTRY=$DOCKER_REGISTRY \
|
||||||
--build-arg GOTENBERG_VERSION=$version \
|
--build-arg DOCKER_REPOSITORY=$DOCKER_REPOSITORY \
|
||||||
--platform $platform \
|
--build-arg GOTENBERG_VERSION=$version \
|
||||||
--load \
|
${tags_aws_lambda_flags[*]} \
|
||||||
${tags_aws_lambda_flags[*]} \
|
-f $DOCKERFILE_AWS_LAMBDA $DOCKER_BUILD_CONTEXT
|
||||||
-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"
|
run_cmd "$cmd"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "✅ Done!"
|
echo "✅ Done!"
|
||||||
echo "tags=$(join "," "${tags[@]}")" >> "$GITHUB_OUTPUT"
|
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=$(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=$(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
|
exit 0
|
||||||
|
|||||||
9
.github/actions/clean/action.yml
vendored
9
.github/actions/clean/action.yml
vendored
@@ -23,12 +23,9 @@ runs:
|
|||||||
env:
|
env:
|
||||||
DOCKERHUB_USERNAME: ${{ inputs.docker_hub_username }}
|
DOCKERHUB_USERNAME: ${{ inputs.docker_hub_username }}
|
||||||
DOCKERHUB_TOKEN: ${{ inputs.docker_hub_password }}
|
DOCKERHUB_TOKEN: ${{ inputs.docker_hub_password }}
|
||||||
INPUT_TAGS: ${{ inputs.tags }}
|
|
||||||
INPUT_SNAPSHOT_VERSION: ${{ inputs.snapshot_version }}
|
|
||||||
INPUT_DRY_RUN: ${{ inputs.dry_run }}
|
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
.github/actions/clean/clean.sh \
|
.github/actions/clean/clean.sh \
|
||||||
--tags "$INPUT_TAGS" \
|
--tags "${{ inputs.tags }}" \
|
||||||
--snapshot-version "$INPUT_SNAPSHOT_VERSION" \
|
--snapshot-version "${{ inputs.snapshot_version }}" \
|
||||||
--dry-run "$INPUT_DRY_RUN"
|
--dry-run "${{ inputs.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
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -41,12 +41,8 @@ runs:
|
|||||||
|
|
||||||
- name: Merge
|
- name: Merge
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
|
||||||
INPUT_TAGS: ${{ inputs.tags }}
|
|
||||||
INPUT_ALTERNATE_REGISTRY: ${{ inputs.alternate_registry }}
|
|
||||||
INPUT_DRY_RUN: ${{ inputs.dry_run }}
|
|
||||||
run: |
|
run: |
|
||||||
.github/actions/merge/merge.sh \
|
.github/actions/merge/merge.sh \
|
||||||
--tags "$INPUT_TAGS" \
|
--tags "${{ inputs.tags }}" \
|
||||||
--alternate-registry "$INPUT_ALTERNATE_REGISTRY" \
|
--alternate-registry "${{ inputs.alternate_registry }}" \
|
||||||
--dry-run "$INPUT_DRY_RUN"
|
--dry-run "${{ inputs.dry_run }}"
|
||||||
|
|||||||
2
.github/dependabot.yml
vendored
2
.github/dependabot.yml
vendored
@@ -16,5 +16,3 @@ updates:
|
|||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
cooldown:
|
|
||||||
default-days: 7
|
|
||||||
|
|||||||
62
.github/workflows/continuous-delivery.yml
vendored
62
.github/workflows/continuous-delivery.yml
vendored
@@ -13,14 +13,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
tags: ${{ steps.build_push.outputs.tags }}
|
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: ${{ 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: ${{ 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:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -40,14 +34,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
tags: ${{ steps.build_push.outputs.tags }}
|
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: ${{ 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: ${{ 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:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -67,14 +55,8 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04-ppc64le
|
runs-on: ubuntu-24.04-ppc64le
|
||||||
outputs:
|
outputs:
|
||||||
tags: ${{ steps.build_push.outputs.tags }}
|
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: ${{ 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: ${{ 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:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -94,14 +76,8 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04-arm
|
runs-on: ubuntu-24.04-arm
|
||||||
outputs:
|
outputs:
|
||||||
tags: ${{ steps.build_push.outputs.tags }}
|
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: ${{ 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: ${{ 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:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -121,14 +97,8 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04-arm
|
runs-on: ubuntu-24.04-arm
|
||||||
outputs:
|
outputs:
|
||||||
tags: ${{ steps.build_push.outputs.tags }}
|
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: ${{ 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: ${{ 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:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -162,20 +132,7 @@ jobs:
|
|||||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
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 }}"
|
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
|
- name: Merge AWS Lambda
|
||||||
uses: ./.github/actions/merge
|
uses: ./.github/actions/merge
|
||||||
@@ -183,24 +140,11 @@ jobs:
|
|||||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
tags: "${{ needs.release_amd64.outputs.tags_aws_lambda }},${{ needs.release_arm64.outputs.tags_aws_lambda }}"
|
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
|
- name: Clean
|
||||||
uses: ./.github/actions/clean
|
uses: ./.github/actions/clean
|
||||||
with:
|
with:
|
||||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
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_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 }}"
|
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 }}"
|
||||||
|
|||||||
124
.github/workflows/continuous-integration.yml
vendored
124
.github/workflows/continuous-integration.yml
vendored
@@ -46,7 +46,7 @@ jobs:
|
|||||||
node-version-file: .node-version
|
node-version-file: .node-version
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm ci --ignore-scripts
|
run: npm i
|
||||||
|
|
||||||
- name: Run linters
|
- name: Run linters
|
||||||
run: make lint-prettier
|
run: make lint-prettier
|
||||||
@@ -77,14 +77,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
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: ${{ 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: ${{ 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:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -107,14 +101,8 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04-ppc64le
|
runs-on: ubuntu-24.04-ppc64le
|
||||||
outputs:
|
outputs:
|
||||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
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: ${{ 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: ${{ 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:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -137,14 +125,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
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: ${{ 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: ${{ 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:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -167,14 +149,8 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04-arm
|
runs-on: ubuntu-24.04-arm
|
||||||
outputs:
|
outputs:
|
||||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
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: ${{ 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: ${{ 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:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -197,14 +173,8 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04-arm
|
runs-on: ubuntu-24.04-arm
|
||||||
outputs:
|
outputs:
|
||||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
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: ${{ 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: ${{ 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:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -262,20 +232,6 @@ jobs:
|
|||||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
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 }}"
|
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
|
- name: Merge AWS Lambda
|
||||||
uses: ./.github/actions/merge
|
uses: ./.github/actions/merge
|
||||||
with:
|
with:
|
||||||
@@ -283,26 +239,12 @@ jobs:
|
|||||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
tags: "${{ needs.snapshot_amd64.outputs.tags_aws_lambda }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda }}"
|
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
|
- name: Clean
|
||||||
uses: ./.github/actions/clean
|
uses: ./.github/actions/clean
|
||||||
with:
|
with:
|
||||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
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_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 }}"
|
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 }}"
|
||||||
|
|
||||||
edge_amd64:
|
edge_amd64:
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
@@ -312,14 +254,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
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: ${{ 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: ${{ 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:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -341,14 +277,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
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: ${{ 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: ${{ 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:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -370,14 +300,8 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04-ppc64le
|
runs-on: ubuntu-24.04-ppc64le
|
||||||
outputs:
|
outputs:
|
||||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
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: ${{ 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: ${{ 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:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -399,14 +323,8 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04-arm
|
runs-on: ubuntu-24.04-arm
|
||||||
outputs:
|
outputs:
|
||||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
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: ${{ 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: ${{ 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:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -428,14 +346,8 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04-arm
|
runs-on: ubuntu-24.04-arm
|
||||||
outputs:
|
outputs:
|
||||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
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: ${{ 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: ${{ 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:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -468,20 +380,7 @@ jobs:
|
|||||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
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 }}"
|
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
|
- name: Merge AWS Lambda
|
||||||
uses: ./.github/actions/merge
|
uses: ./.github/actions/merge
|
||||||
@@ -489,24 +388,11 @@ jobs:
|
|||||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
tags: "${{ needs.edge_amd64.outputs.tags_aws_lambda }},${{ needs.edge_arm64.outputs.tags_aws_lambda }}"
|
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
|
- name: Clean
|
||||||
uses: ./.github/actions/clean
|
uses: ./.github/actions/clean
|
||||||
with:
|
with:
|
||||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
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_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 }}"
|
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 }}"
|
||||||
|
|||||||
@@ -24,14 +24,13 @@ linters:
|
|||||||
- ineffassign
|
- ineffassign
|
||||||
- misspell
|
- misspell
|
||||||
- prealloc
|
- prealloc
|
||||||
|
- promlinter
|
||||||
- staticcheck
|
- staticcheck
|
||||||
- testableexamples
|
- testableexamples
|
||||||
- tparallel
|
- tparallel
|
||||||
- unconvert
|
- unconvert
|
||||||
- unused
|
- unused
|
||||||
- sloglint
|
|
||||||
- usetesting
|
- usetesting
|
||||||
- gocritic
|
|
||||||
- wastedassign
|
- wastedassign
|
||||||
- whitespace
|
- whitespace
|
||||||
exclusions:
|
exclusions:
|
||||||
@@ -41,6 +40,10 @@ linters:
|
|||||||
- common-false-positives
|
- common-false-positives
|
||||||
- legacy
|
- legacy
|
||||||
- std-error-handling
|
- std-error-handling
|
||||||
|
paths:
|
||||||
|
- third_party$
|
||||||
|
- builtin$
|
||||||
|
- examples$
|
||||||
formatters:
|
formatters:
|
||||||
enable:
|
enable:
|
||||||
- gci
|
- gci
|
||||||
@@ -56,3 +59,7 @@ formatters:
|
|||||||
custom-order: true
|
custom-order: true
|
||||||
exclusions:
|
exclusions:
|
||||||
generated: lax
|
generated: lax
|
||||||
|
paths:
|
||||||
|
- third_party$
|
||||||
|
- builtin$
|
||||||
|
- examples$
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
24.15.0
|
24.11.0
|
||||||
|
|||||||
66
AGENTS.md
Normal file
66
AGENTS.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# 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
178
CONTRIBUTING.md
@@ -1,178 +0,0 @@
|
|||||||
# 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,9 +5,8 @@ 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}'
|
@grep -hE '^[A-Za-z0-9_ \-]*?:.*##.*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
build: ## Build the Gotenberg's Docker image (use TARGET=gotenberg-chromium or TARGET=gotenberg-libreoffice for variants)
|
build: ## Build the Gotenberg's Docker image
|
||||||
docker build \
|
docker build \
|
||||||
--target $(TARGET) \
|
|
||||||
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
|
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
|
||||||
-f $(DOCKERFILE) $(DOCKER_BUILD_CONTEXT)
|
-f $(DOCKERFILE) $(DOCKER_BUILD_CONTEXT)
|
||||||
|
|
||||||
@@ -27,17 +26,16 @@ API_ENABLE_BASIC_AUTH=false
|
|||||||
GOTENBERG_API_BASIC_AUTH_USERNAME=
|
GOTENBERG_API_BASIC_AUTH_USERNAME=
|
||||||
GOTENBERG_API_BASIC_AUTH_PASSWORD=
|
GOTENBERG_API_BASIC_AUTH_PASSWORD=
|
||||||
API_DOWNLOAD_FROM_ALLOW_LIST=
|
API_DOWNLOAD_FROM_ALLOW_LIST=
|
||||||
API_DOWNLOAD_FROM_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)
|
API_DOWNLOAD_FROM_DENY_LIST=
|
||||||
API_DOWNLOAD_FROM_MAX_RETRY=4
|
API_DOWNLOAD_FROM_MAX_RETRY=4
|
||||||
API_DISABLE_DOWNLOAD_FROM=false
|
API_DISABLE_DOWNLOAD_FROM=false
|
||||||
API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=true
|
API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=false
|
||||||
API_DISABLE_ROOT_ROUTE_TELEMETRY=true
|
API_DISABLE_ROOT_ROUTE_TELEMETRY=false
|
||||||
API_DISABLE_DEBUG_ROUTE_TELEMETRY=true
|
API_DISABLE_VERSION_ROUTE_TELEMETRY=false
|
||||||
API_DISABLE_VERSION_ROUTE_TELEMETRY=true
|
API_DISABLE_DEBUG_ROUTE_TELEMETRY=false
|
||||||
API_ENABLE_DEBUG_ROUTE=false
|
API_ENABLE_DEBUG_ROUTE=false
|
||||||
CHROMIUM_RESTART_AFTER=100
|
CHROMIUM_RESTART_AFTER=100
|
||||||
CHROMIUM_MAX_QUEUE_SIZE=0
|
CHROMIUM_MAX_QUEUE_SIZE=0
|
||||||
CHROMIUM_IDLE_SHUTDOWN_TIMEOUT=0
|
|
||||||
CHROMIUM_MAX_CONCURRENCY=6
|
CHROMIUM_MAX_CONCURRENCY=6
|
||||||
CHROMIUM_AUTO_START=false
|
CHROMIUM_AUTO_START=false
|
||||||
CHROMIUM_START_TIMEOUT=20s
|
CHROMIUM_START_TIMEOUT=20s
|
||||||
@@ -55,36 +53,22 @@ CHROMIUM_DISABLE_JAVASCRIPT=false
|
|||||||
CHROMIUM_DISABLE_ROUTES=false
|
CHROMIUM_DISABLE_ROUTES=false
|
||||||
LIBREOFFICE_RESTART_AFTER=10
|
LIBREOFFICE_RESTART_AFTER=10
|
||||||
LIBREOFFICE_MAX_QUEUE_SIZE=0
|
LIBREOFFICE_MAX_QUEUE_SIZE=0
|
||||||
LIBREOFFICE_IDLE_SHUTDOWN_TIMEOUT=0
|
|
||||||
LIBREOFFICE_AUTO_START=false
|
LIBREOFFICE_AUTO_START=false
|
||||||
LIBREOFFICE_START_TIMEOUT=20s
|
LIBREOFFICE_START_TIMEOUT=20s
|
||||||
LIBREOFFICE_ALLOW_LIST=
|
|
||||||
LIBREOFFICE_DENY_LIST=
|
|
||||||
LIBREOFFICE_DISABLE_ROUTES=false
|
LIBREOFFICE_DISABLE_ROUTES=false
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
LOG_FIELDS_PREFIX=
|
LOG_FIELDS_PREFIX=
|
||||||
LOG_STD_FORMAT=auto
|
LOG_STD_FORMAT=auto
|
||||||
LOG_STD_ENABLE_GCP_FIELDS=false
|
LOG_STD_ENABLE_GCP_FIELDS=false
|
||||||
PDFENGINES_DISABLE_ROUTES=false
|
|
||||||
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
|
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
|
||||||
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
|
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
|
||||||
PDFENGINES_FLATTEN_ENGINES=qpdf
|
PDFENGINES_FLATTEN_ENGINES=qpdf
|
||||||
PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
|
PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
|
||||||
PDFENGINES_READ_METADATA_ENGINES=exiftool
|
PDFENGINES_READ_METADATA_ENGINES=exiftool
|
||||||
PDFENGINES_WRITE_METADATA_ENGINES=exiftool
|
PDFENGINES_WRITE_METADATA_ENGINES=exiftool
|
||||||
PDFENGINES_READ_BOOKMARKS_ENGINES=pdfcpu
|
|
||||||
PDFENGINES_WRITE_BOOKMARKS_ENGINES=pdfcpu
|
|
||||||
PDFENGINES_WATERMARK_ENGINES=pdfcpu,pdftk
|
|
||||||
PDFENGINES_STAMP_ENGINES=pdfcpu,pdftk
|
|
||||||
PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
|
PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
|
||||||
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
|
PDFENGINES_DISABLE_ROUTES=false
|
||||||
PDFENGINES_EMBED_ENGINES=qpdf,pdfcpu
|
PDFENGINES_ADD_ATTACHMENTS_ENGINES=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_SERVICE_NAME=gotenberg
|
||||||
OTEL_TRACES_EXPORTER=none
|
OTEL_TRACES_EXPORTER=none
|
||||||
OTEL_METRICS_EXPORTER=none
|
OTEL_METRICS_EXPORTER=none
|
||||||
@@ -94,7 +78,9 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
|||||||
OTEL_EXPORTER_OTLP_INSECURE=true
|
OTEL_EXPORTER_OTLP_INSECURE=true
|
||||||
WEBHOOK_ENABLE_SYNC_MODE=false
|
WEBHOOK_ENABLE_SYNC_MODE=false
|
||||||
WEBHOOK_ALLOW_LIST=
|
WEBHOOK_ALLOW_LIST=
|
||||||
WEBHOOK_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)
|
WEBHOOK_DENY_LIST=
|
||||||
|
WEBHOOK_ERROR_ALLOW_LIST=
|
||||||
|
WEBHOOK_ERROR_DENY_LIST=
|
||||||
WEBHOOK_MAX_RETRY=4
|
WEBHOOK_MAX_RETRY=4
|
||||||
WEBHOOK_RETRY_MIN_WAIT=1s
|
WEBHOOK_RETRY_MIN_WAIT=1s
|
||||||
WEBHOOK_RETRY_MAX_WAIT=30s
|
WEBHOOK_RETRY_MAX_WAIT=30s
|
||||||
@@ -128,9 +114,6 @@ NO_CONCURRENCY=false
|
|||||||
# chromium-convert-html
|
# chromium-convert-html
|
||||||
# chromium-convert-markdown
|
# chromium-convert-markdown
|
||||||
# chromium-convert-url
|
# chromium-convert-url
|
||||||
# chromium-screenshot-html
|
|
||||||
# chromium-screenshot-markdown
|
|
||||||
# chromium-screenshot-url
|
|
||||||
# debug
|
# debug
|
||||||
# health
|
# health
|
||||||
# libreoffice
|
# libreoffice
|
||||||
@@ -138,8 +121,8 @@ NO_CONCURRENCY=false
|
|||||||
# output-filename
|
# output-filename
|
||||||
# pdfengines
|
# pdfengines
|
||||||
# pdfengines-convert
|
# pdfengines-convert
|
||||||
# pdfengines-embed
|
# pdfengines-attachments
|
||||||
# embed
|
# attachments
|
||||||
# pdfengines-encrypt
|
# pdfengines-encrypt
|
||||||
# encrypt
|
# encrypt
|
||||||
# pdfengines-flatten
|
# pdfengines-flatten
|
||||||
@@ -150,23 +133,15 @@ NO_CONCURRENCY=false
|
|||||||
# metadata
|
# metadata
|
||||||
# pdfengines-split
|
# pdfengines-split
|
||||||
# split
|
# split
|
||||||
# pdfengines-watermark
|
|
||||||
# watermark
|
|
||||||
# pdfengines-stamp
|
|
||||||
# stamp
|
|
||||||
# pdfengines-rotate
|
|
||||||
# rotate
|
|
||||||
# pdfengines-bookmarks
|
|
||||||
# bookmarks
|
|
||||||
# prometheus-metrics
|
|
||||||
# root
|
# root
|
||||||
# version
|
# version
|
||||||
# webhook
|
# webhook
|
||||||
# download-from
|
# download-from
|
||||||
|
# telemetry
|
||||||
TAGS=
|
TAGS=
|
||||||
|
|
||||||
.PHONY: test-integration
|
.PHONY: test-integration
|
||||||
test-integration: ## Run integration tests (automatically retries failed scenarios up to 3 times)
|
test-integration: ## Run integration tests
|
||||||
go test -timeout 40m -tags=integration -v github.com/gotenberg/gotenberg/v8/test/integration -args \
|
go test -timeout 40m -tags=integration -v github.com/gotenberg/gotenberg/v8/test/integration -args \
|
||||||
--gotenberg-docker-repository=$(DOCKER_REPOSITORY) \
|
--gotenberg-docker-repository=$(DOCKER_REPOSITORY) \
|
||||||
--gotenberg-version=$(GOTENBERG_VERSION) \
|
--gotenberg-version=$(GOTENBERG_VERSION) \
|
||||||
|
|||||||
48
README.md
48
README.md
@@ -1,7 +1,7 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://user-images.githubusercontent.com/8983173/130322857-185831e2-f041-46eb-a17f-0a69d066c4e5.png" alt="Gotenberg Logo" width="150" height="150" />
|
<img src="https://user-images.githubusercontent.com/8983173/130322857-185831e2-f041-46eb-a17f-0a69d066c4e5.png" alt="Gotenberg Logo" width="150" height="150" />
|
||||||
<h3 align="center">Gotenberg</h3>
|
<h3 align="center">Gotenberg</h3>
|
||||||
<p align="center">A Docker-based API for converting documents to PDF</p>
|
<p align="center">A containerized API for seamless PDF conversion</p>
|
||||||
<p align="center">
|
<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://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>
|
<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,60 +10,38 @@
|
|||||||
<p align="center">
|
<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>
|
<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>
|
||||||
<p align="center">
|
<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>
|
||||||
<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>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**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.
|
**Gotenberg** is a containerized API that abstracts the complexity of PDF conversion.
|
||||||
|
|
||||||
Send your files via `multipart/form-data`, get a PDF back. No need to manage Chromium, LibreOffice, or fonts yourself.
|
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.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
Open a terminal and run the following command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --rm -p 3000:3000 gotenberg/gotenberg:8
|
docker run --rm -p 3000:3000 gotenberg/gotenberg:8
|
||||||
```
|
```
|
||||||
|
|
||||||
Convert a URL to PDF:
|
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,
|
||||||
```bash
|
local files, inject custom CSS, merge PDFs, and more.
|
||||||
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
|
## Sponsors
|
||||||
|
|
||||||
If Gotenberg powers your workflow or your business, consider [**becoming a sponsor**](https://github.com/sponsors/gulien).
|
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)! ❤️
|
||||||
|
|
||||||
**Historic & GitHub Sponsors**
|
**Historic & GitHub Sponsors**
|
||||||
|
|
||||||
- [TheCodingMachine](https://thecodingmachine.com/)
|
- [TheCodingMachine](https://thecodingmachine.com/)
|
||||||
- [pdfme](https://pdfme.com/)
|
- [pdfme](https://pdfme.com/)
|
||||||
- [PDFBolt](https://pdfbolt.com)
|
|
||||||
|
|
||||||
**Powered By**
|
**Powered By**
|
||||||
|
|
||||||
|
|||||||
34
SECURITY.md
34
SECURITY.md
@@ -2,28 +2,40 @@
|
|||||||
|
|
||||||
## Supported Versions
|
## Supported Versions
|
||||||
|
|
||||||
Only the latest version receives security updates and patches. Keep your environment up to date.
|
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.
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
Do not publish vulnerabilities publicly. Report them through [GitHub's private vulnerability reporting](https://github.com/gotenberg/gotenberg/security/advisories/new).
|
Your help in identifying vulnerabilities in our project is much appreciated.
|
||||||
|
We take all reports regarding security seriously.
|
||||||
|
|
||||||
Include:
|
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:
|
||||||
|
|
||||||
- A detailed description of the vulnerability.
|
- A detailed description of the vulnerability.
|
||||||
- Steps to reproduce the issue.
|
- The steps to reproduce the issue.
|
||||||
- Potential impact on users or the system.
|
- Any potential impact of the vulnerability on the users or system.
|
||||||
|
|
||||||
This process is handled on a best-effort basis. Response speed may vary depending on severity and available resources.
|
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!
|
||||||
|
|
||||||
## Disclosure Policy
|
## Disclosure Policy
|
||||||
|
|
||||||
Once a report is received and confirmed:
|
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:
|
||||||
|
|
||||||
- A fix and release timeline will be prepared.
|
- Work on a fix and a release timeline.
|
||||||
- The reporter will be notified when the fix is released.
|
- Notify you when the fix has been implemented and released.
|
||||||
- The reporter will be credited for the discovery (unless anonymity is requested).
|
- 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.
|
||||||
|
|
||||||
## Comments on this Policy
|
## Comments on this Policy
|
||||||
|
|
||||||
Submit a pull request with suggestions for improving this process.
|
If you have suggestions on how this process could be improved, please submit a pull request.
|
||||||
|
|||||||
468
build/Dockerfile
468
build/Dockerfile
@@ -1,7 +1,7 @@
|
|||||||
# ARG instructions do not create additional layers. Instead, next layers will
|
# ARG instructions do not create additional layers. Instead, next layers will
|
||||||
# concatenate them. Also, we have to repeat ARG instructions in each build
|
# concatenate them. Also, we have to repeat ARG instructions in each build
|
||||||
# stage that uses them.
|
# stage that uses them.
|
||||||
ARG GOLANG_VERSION=1.26.2
|
ARG GOLANG_VERSION=1.26.0
|
||||||
|
|
||||||
# ----------------------------------------------
|
# ----------------------------------------------
|
||||||
# pdfcpu binary build stage
|
# pdfcpu binary build stage
|
||||||
@@ -11,22 +11,22 @@ ARG GOLANG_VERSION=1.26.2
|
|||||||
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
|
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
|
||||||
|
|
||||||
# See https://github.com/pdfcpu/pdfcpu/releases.
|
# See https://github.com/pdfcpu/pdfcpu/releases.
|
||||||
ARG PDFCPU_VERSION=v0.12.0
|
ARG PDFCPU_VERSION=v0.11.1
|
||||||
ENV CGO_ENABLED=0
|
ENV CGO_ENABLED=0
|
||||||
|
|
||||||
# Define the working directory outside of $GOPATH (we're using go modules).
|
# Define the working directory outside of $GOPATH (we're using go modules).
|
||||||
WORKDIR /home
|
WORKDIR /home
|
||||||
|
|
||||||
RUN curl -Ls "https://github.com/pdfcpu/pdfcpu/archive/refs/tags/$PDFCPU_VERSION.tar.gz" -o 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
|
tar --strip-components=1 -xvzf pdfcpu.tar.gz
|
||||||
|
|
||||||
# Install module dependencies.
|
# Install module dependencies.
|
||||||
RUN go mod download \
|
RUN go mod download &&\
|
||||||
&& go mod verify
|
go mod verify
|
||||||
|
|
||||||
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 \
|
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 &&\
|
||||||
# Verify installation.
|
# Verify installation.
|
||||||
&& ./pdfcpu version
|
./pdfcpu version
|
||||||
|
|
||||||
# ----------------------------------------------
|
# ----------------------------------------------
|
||||||
# Gotenberg binary build stage
|
# Gotenberg binary build stage
|
||||||
@@ -42,16 +42,14 @@ WORKDIR /home
|
|||||||
# Install module dependencies.
|
# Install module dependencies.
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
|
|
||||||
RUN go mod download \
|
RUN go mod download &&\
|
||||||
&& go mod verify
|
go mod verify
|
||||||
|
|
||||||
# Copy the source code.
|
# Copy the source code.
|
||||||
COPY cmd ./cmd
|
COPY cmd ./cmd
|
||||||
COPY pkg ./pkg
|
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
|
# Custom JRE stage
|
||||||
@@ -59,13 +57,14 @@ RUN go build -o gotenberg -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/
|
|||||||
# ----------------------------------------------
|
# ----------------------------------------------
|
||||||
FROM debian:13-slim AS custom-jre-stage
|
FROM debian:13-slim AS custom-jre-stage
|
||||||
|
|
||||||
RUN apt-get update -qq \
|
RUN \
|
||||||
&& apt-get upgrade -yqq \
|
apt-get update -qq &&\
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends default-jdk-headless binutils
|
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.
|
# Note: jdeps helps finding which modules a JAR requires.
|
||||||
# Currently only for PDFtk, as we don't rely on LibreOffice UNO Java SDK.
|
# Currently only for PDFtk, as we don't rely on LibreOffice UNO Java SDK.
|
||||||
ENV JAVA_MODULES=java.base,java.desktop,java.naming,java.sql
|
ENV JAVA_MODULES=java.base,java.desktop,java.xml
|
||||||
|
|
||||||
RUN jlink \
|
RUN jlink \
|
||||||
--add-modules $JAVA_MODULES \
|
--add-modules $JAVA_MODULES \
|
||||||
@@ -83,16 +82,16 @@ FROM debian:13-slim AS downloader-stage
|
|||||||
# See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
|
# See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
|
||||||
ARG PDFTK_VERSION=v3.3.3
|
ARG PDFTK_VERSION=v3.3.3
|
||||||
|
|
||||||
RUN apt-get update -qq \
|
RUN apt-get update -qq && \
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl ca-certificates
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl ca-certificates
|
||||||
|
|
||||||
WORKDIR /downloads
|
WORKDIR /downloads
|
||||||
|
|
||||||
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.2.0/unoconv -o unoconverter \
|
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.2.0/unoconv -o unoconverter &&\
|
||||||
&& chmod +x unoconverter
|
chmod +x unoconverter
|
||||||
|
|
||||||
RUN curl -o pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" \
|
RUN curl -o pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" &&\
|
||||||
&& chmod a+x pdftk-all.jar
|
chmod a+x pdftk-all.jar
|
||||||
|
|
||||||
# ----------------------------------------------
|
# ----------------------------------------------
|
||||||
# Base image stage
|
# Base image stage
|
||||||
@@ -101,33 +100,48 @@ FROM debian:13-slim AS base-image-stage
|
|||||||
|
|
||||||
ARG TIMEZONE=UTC
|
ARG TIMEZONE=UTC
|
||||||
ENV TZ=$TIMEZONE
|
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
|
COPY --link --from=custom-jre-stage /custom-jre /opt/java
|
||||||
|
|
||||||
ENV PATH="/opt/java/bin:${PATH}"
|
ENV PATH="/opt/java/bin:${PATH}"
|
||||||
|
|
||||||
# ----------------------------------------------
|
# ----------------------------------------------
|
||||||
# Common stage
|
# Final stage
|
||||||
# ----------------------------------------------
|
# ----------------------------------------------
|
||||||
FROM base-image-stage AS common-stage
|
FROM base-image-stage
|
||||||
|
|
||||||
|
ARG GOTENBERG_VERSION=snapshot
|
||||||
ARG GOTENBERG_USER_GID=1001
|
ARG GOTENBERG_USER_GID=1001
|
||||||
ARG GOTENBERG_USER_UID=1001
|
ARG GOTENBERG_USER_UID=1001
|
||||||
|
|
||||||
# Create a non-root user.
|
LABEL org.opencontainers.image.title="Gotenberg" \
|
||||||
# All processes in the Docker container will run with this dedicated user.
|
org.opencontainers.image.description="A containerized API for seamless PDF conversion." \
|
||||||
RUN groupadd --gid "$GOTENBERG_USER_GID" gotenberg \
|
org.opencontainers.image.version="$GOTENBERG_VERSION" \
|
||||||
&& useradd --uid "$GOTENBERG_USER_UID" --gid gotenberg --shell /bin/bash --home /home/gotenberg --no-create-home gotenberg \
|
org.opencontainers.image.authors="Julien Neuhart <neuhart.julien@gmail.com>" \
|
||||||
&& mkdir /home/gotenberg \
|
org.opencontainers.image.documentation="https://gotenberg.dev" \
|
||||||
&& chown gotenberg: /home/gotenberg
|
org.opencontainers.image.source="https://github.com/gotenberg/gotenberg"
|
||||||
|
|
||||||
# Note: tini is a helper for reaping zombie processes.
|
RUN \
|
||||||
RUN apt-get update -qq \
|
# Create a non-root user.
|
||||||
&& apt-get upgrade -yqq \
|
# All processes in the Docker container will run with this dedicated user.
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
|
groupadd --gid "$GOTENBERG_USER_GID" gotenberg &&\
|
||||||
tini \
|
useradd --uid "$GOTENBERG_USER_UID" --gid gotenberg --shell /bin/bash --home /home/gotenberg --no-create-home gotenberg &&\
|
||||||
# Many users rely on curl for Docker health checks.
|
mkdir /home/gotenberg &&\
|
||||||
curl \
|
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 \
|
||||||
# Install fonts.
|
# Install fonts.
|
||||||
# Essential metric-compatible fonts for LibreOffice layout fidelity (replaces MS Fonts).
|
# Essential metric-compatible fonts for LibreOffice layout fidelity (replaces MS Fonts).
|
||||||
fonts-crosextra-carlito \
|
fonts-crosextra-carlito \
|
||||||
@@ -140,29 +154,46 @@ RUN apt-get update -qq \
|
|||||||
fonts-noto-cjk \
|
fonts-noto-cjk \
|
||||||
# Standard Emoji support.
|
# Standard Emoji support.
|
||||||
fonts-noto-color-emoji \
|
fonts-noto-color-emoji \
|
||||||
# Tofu prevention.
|
# Install Hyphenation for LibreOffice.
|
||||||
fonts-noto-core \
|
# 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 \
|
||||||
# Install QPDF & ExifTool (PDF engines).
|
# Install QPDF & ExifTool (PDF engines).
|
||||||
qpdf exiftool \
|
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 &&\
|
||||||
# See https://github.com/nextcloud/docker/issues/380.
|
# See https://github.com/nextcloud/docker/issues/380.
|
||||||
&& mkdir -p /usr/share/man/man1 \
|
mkdir -p /usr/share/man/man1 &&\
|
||||||
# Cleanup.
|
# Cleanup.
|
||||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
apt-get autoremove -yqq &&\
|
||||||
|
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/share/doc/* /usr/share/man/* /usr/share/info/*
|
||||||
|
|
||||||
# PDFtk bash wrapper.
|
# Copy downloaded artifacts (unoconverter & PDFtk).
|
||||||
# See https://github.com/gotenberg/gotenberg/pull/273.
|
COPY --link --from=downloader-stage /downloads/unoconverter /usr/bin/unoconverter
|
||||||
RUN printf '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk \
|
COPY --link --from=downloader-stage /downloads/pdftk-all.jar /usr/bin/pdftk-all.jar
|
||||||
&& chmod +x /usr/bin/pdftk
|
|
||||||
|
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
|
||||||
|
|
||||||
# Support for arbitrary user IDs (OpenShift).
|
# Support for arbitrary user IDs (OpenShift).
|
||||||
# See:
|
# See:
|
||||||
# https://github.com/gotenberg/gotenberg/issues/1049.
|
# 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.
|
# https://docs.redhat.com/en/documentation/openshift_container_platform/4.15/html/images/creating-images#use-uid_create-images.
|
||||||
RUN usermod -aG root gotenberg \
|
RUN \
|
||||||
&& chgrp -R 0 /home/gotenberg \
|
usermod -aG root gotenberg &&\
|
||||||
&& chmod -R g=u /home/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.
|
# Improve fonts subpixel hinting and smoothing.
|
||||||
# Credits:
|
# Credits:
|
||||||
@@ -170,104 +201,24 @@ RUN usermod -aG root gotenberg \
|
|||||||
# https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d.
|
# https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d.
|
||||||
COPY --link build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf
|
COPY --link build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf
|
||||||
|
|
||||||
# Copy PDFtk jar.
|
# Copy dictionnaries so that hypens work on Chromium.
|
||||||
COPY --link --from=downloader-stage /downloads/pdftk-all.jar /usr/bin/pdftk-all.jar
|
# 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 the pdfcpu binary (shared across all variants).
|
# Copy the Golang binaries.
|
||||||
COPY --link --from=pdfcpu-binary-stage /home/pdfcpu /usr/bin/
|
COPY --link --from=pdfcpu-binary-stage /home/pdfcpu /usr/bin/
|
||||||
|
COPY --link --from=gotenberg-binary-stage /home/gotenberg /usr/bin/
|
||||||
|
|
||||||
# Set default characterset encoding to UTF-8.
|
# Environment variables required by modules or else.
|
||||||
# See:
|
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
|
||||||
# https://github.com/gotenberg/gotenberg/issues/104
|
ENV CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data
|
||||||
# https://github.com/gotenberg/gotenberg/issues/730
|
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
|
||||||
ENV LANG=C.UTF-8
|
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
|
||||||
ENV LC_ALL=C.UTF-8
|
|
||||||
|
|
||||||
# Environment variables for PDF engines.
|
|
||||||
ENV PDFTK_BIN_PATH=/usr/bin/pdftk
|
ENV PDFTK_BIN_PATH=/usr/bin/pdftk
|
||||||
ENV QPDF_BIN_PATH=/usr/bin/qpdf
|
ENV QPDF_BIN_PATH=/usr/bin/qpdf
|
||||||
ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool
|
ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool
|
||||||
ENV PDFCPU_BIN_PATH=/usr/bin/pdfcpu
|
ENV PDFCPU_BIN_PATH=/usr/bin/pdfcpu
|
||||||
|
|
||||||
# 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
|
USER gotenberg
|
||||||
WORKDIR /home/gotenberg
|
WORKDIR /home/gotenberg
|
||||||
|
|
||||||
@@ -275,235 +226,4 @@ WORKDIR /home/gotenberg
|
|||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
ENTRYPOINT [ "/usr/bin/tini", "--" ]
|
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
|
|
||||||
21
build/Dockerfile.aws-lambda
Normal file
21
build/Dockerfile.aws-lambda
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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
|
||||||
22
build/Dockerfile.cloudrun
Normal file
22
build/Dockerfile.cloudrun
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
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
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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 Docker-based API for converting documents to PDF.
|
A containerized API for seamless PDF conversion.
|
||||||
Version: %s
|
Version: %s
|
||||||
-------------------------------------------------------
|
-------------------------------------------------------
|
||||||
`
|
`
|
||||||
@@ -43,24 +43,10 @@ func Run() {
|
|||||||
fs.Bool("gotenberg-hide-banner", false, "Hide the banner")
|
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.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.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))
|
||||||
// Logging & telemetry flags.
|
fs.String("log-fields-prefix", "", "Prepend a specified prefix to each field in the logs")
|
||||||
fs.String("log-level", gotenberg.InfoLoggingLevel, "Set the log level")
|
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.String("log-fields-prefix", "", "Prepend a specified prefix to each log field key")
|
fs.Bool("log-std-enable-gcp-fields", false, "Enable Google Cloud Platform fields for standard logging - namely: time, message, severity")
|
||||||
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()
|
descriptors := gotenberg.GetModuleDescriptors()
|
||||||
var modsInfo strings.Builder
|
var modsInfo strings.Builder
|
||||||
@@ -94,11 +80,10 @@ func Run() {
|
|||||||
fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err)
|
fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
f.Changed = true
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = fs.Set(f.Name, val)
|
err = f.Value.Set(val)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err)
|
fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -110,43 +95,43 @@ func Run() {
|
|||||||
hideBanner := parsedFlags.MustBool("gotenberg-hide-banner")
|
hideBanner := parsedFlags.MustBool("gotenberg-hide-banner")
|
||||||
gracefulShutdownDuration := parsedFlags.MustDuration("gotenberg-graceful-shutdown-duration")
|
gracefulShutdownDuration := parsedFlags.MustDuration("gotenberg-graceful-shutdown-duration")
|
||||||
|
|
||||||
// Initialize telemetry (logging + OTEL).
|
|
||||||
serviceName := os.Getenv("OTEL_SERVICE_NAME")
|
serviceName := os.Getenv("OTEL_SERVICE_NAME")
|
||||||
if serviceName == "" {
|
if serviceName == "" {
|
||||||
serviceName = "gotenberg"
|
serviceName = "gotenberg"
|
||||||
}
|
}
|
||||||
|
|
||||||
telemetryCfg := gotenberg.TelemetryConfig{
|
telemetryConfig := gotenberg.TelemetryConfig{
|
||||||
ServiceName: serviceName,
|
// OpenTelemetry.
|
||||||
ServiceVersion: Version,
|
ServiceName: serviceName,
|
||||||
LogLevel: parsedFlags.MustDeprecatedString("log-format", "log-std-format"),
|
ServiceVersion: Version,
|
||||||
|
// Logging.
|
||||||
|
LogLevel: parsedFlags.MustString("log-level"),
|
||||||
LogFieldsPrefix: parsedFlags.MustString("log-fields-prefix"),
|
LogFieldsPrefix: parsedFlags.MustString("log-fields-prefix"),
|
||||||
LogStdFormat: parsedFlags.MustDeprecatedString("log-format", "log-std-format"),
|
LogStdFormat: parsedFlags.MustDeprecatedString("log-format", "log-std-format"),
|
||||||
LogStdEnableGcpFields: parsedFlags.MustDeprecatedBool("log-enable-gcp-fields", "log-std-enable-gcp-fields"),
|
LogStdEnableGcpFields: parsedFlags.MustDeprecatedBool("log-enable-gcp-fields", "log-std-enable-gcp-fields"),
|
||||||
}
|
}
|
||||||
// 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 {
|
if !hideBanner {
|
||||||
fmt.Printf(banner, Version)
|
fmt.Printf(banner, Version)
|
||||||
}
|
}
|
||||||
fmt.Printf("[SYSTEM] modules: %s\n", modsInfo.String())
|
|
||||||
|
|
||||||
|
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)
|
ctx := gotenberg.NewContext(parsedFlags, descriptors)
|
||||||
|
|
||||||
// Start application modules.
|
|
||||||
apps, err := ctx.Modules(new(gotenberg.App))
|
apps, err := ctx.Modules(new(gotenberg.App))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[FATAL] %s\n", err)
|
fmt.Printf("[FATAL] %s\n", err)
|
||||||
@@ -238,18 +223,16 @@ func Run() {
|
|||||||
|
|
||||||
err = eg.Wait()
|
err = eg.Wait()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cancel()
|
|
||||||
fmt.Printf("[FATAL] %v\n", err)
|
fmt.Printf("[FATAL] %v\n", err)
|
||||||
os.Exit(1) //nolint:gocritic // defers are already called explicitly above
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shutdown telemetry (flush spans, metrics, logs).
|
|
||||||
err = shutdownTelemetry(gracefulShutdownCtx)
|
err = shutdownTelemetry(gracefulShutdownCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cancel()
|
fmt.Printf("[FATAL] telemetry: %v\n", err)
|
||||||
fmt.Printf("[FATAL] %v\n", err)
|
os.Exit(1)
|
||||||
os.Exit(1) //nolint:gocritic // defers are already called explicitly above
|
|
||||||
}
|
}
|
||||||
|
fmt.Printf("[SYSTEM] telemetry: shutdown success\n")
|
||||||
|
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|||||||
31
compose.yaml
31
compose.yaml
@@ -33,13 +33,12 @@ services:
|
|||||||
- "--api-disable-download-from=${API_DISABLE_DOWNLOAD_FROM}"
|
- "--api-disable-download-from=${API_DISABLE_DOWNLOAD_FROM}"
|
||||||
- "--api-disable-health-check-route-telemetry=${API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY}"
|
- "--api-disable-health-check-route-telemetry=${API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY}"
|
||||||
- "--api-disable-root-route-telemetry=${API_DISABLE_ROOT_ROUTE_TELEMETRY}"
|
- "--api-disable-root-route-telemetry=${API_DISABLE_ROOT_ROUTE_TELEMETRY}"
|
||||||
- "--api-disable-debug-route-telemetry=${API_DISABLE_DEBUG_ROUTE_TELEMETRY}"
|
|
||||||
- "--api-disable-version-route-telemetry=${API_DISABLE_VERSION_ROUTE_TELEMETRY}"
|
- "--api-disable-version-route-telemetry=${API_DISABLE_VERSION_ROUTE_TELEMETRY}"
|
||||||
|
- "--api-disable-debug-route-telemetry=${API_DISABLE_DEBUG_ROUTE_TELEMETRY}"
|
||||||
- "--api-enable-debug-route=${API_ENABLE_DEBUG_ROUTE}"
|
- "--api-enable-debug-route=${API_ENABLE_DEBUG_ROUTE}"
|
||||||
- "--chromium-restart-after=${CHROMIUM_RESTART_AFTER}"
|
- "--chromium-restart-after=${CHROMIUM_RESTART_AFTER}"
|
||||||
- "--chromium-auto-start=${CHROMIUM_AUTO_START}"
|
- "--chromium-auto-start=${CHROMIUM_AUTO_START}"
|
||||||
- "--chromium-max-queue-size=${CHROMIUM_MAX_QUEUE_SIZE}"
|
- "--chromium-max-queue-size=${CHROMIUM_MAX_QUEUE_SIZE}"
|
||||||
- "--chromium-idle-shutdown-timeout=${CHROMIUM_IDLE_SHUTDOWN_TIMEOUT}"
|
|
||||||
- "--chromium-max-concurrency=${CHROMIUM_MAX_CONCURRENCY}"
|
- "--chromium-max-concurrency=${CHROMIUM_MAX_CONCURRENCY}"
|
||||||
- "--chromium-start-timeout=${CHROMIUM_START_TIMEOUT}"
|
- "--chromium-start-timeout=${CHROMIUM_START_TIMEOUT}"
|
||||||
- "--chromium-allow-insecure-localhost=${CHROMIUM_ALLOW_INSECURE_LOCALHOST}"
|
- "--chromium-allow-insecure-localhost=${CHROMIUM_ALLOW_INSECURE_LOCALHOST}"
|
||||||
@@ -56,11 +55,8 @@ services:
|
|||||||
- "--chromium-disable-routes=${CHROMIUM_DISABLE_ROUTES}"
|
- "--chromium-disable-routes=${CHROMIUM_DISABLE_ROUTES}"
|
||||||
- "--libreoffice-restart-after=${LIBREOFFICE_RESTART_AFTER}"
|
- "--libreoffice-restart-after=${LIBREOFFICE_RESTART_AFTER}"
|
||||||
- "--libreoffice-max-queue-size=${LIBREOFFICE_MAX_QUEUE_SIZE}"
|
- "--libreoffice-max-queue-size=${LIBREOFFICE_MAX_QUEUE_SIZE}"
|
||||||
- "--libreoffice-idle-shutdown-timeout=${LIBREOFFICE_IDLE_SHUTDOWN_TIMEOUT}"
|
|
||||||
- "--libreoffice-auto-start=${LIBREOFFICE_AUTO_START}"
|
- "--libreoffice-auto-start=${LIBREOFFICE_AUTO_START}"
|
||||||
- "--libreoffice-start-timeout=${LIBREOFFICE_START_TIMEOUT}"
|
- "--libreoffice-start-timeout=${LIBREOFFICE_START_TIMEOUT}"
|
||||||
- "--libreoffice-allow-list=${LIBREOFFICE_ALLOW_LIST}"
|
|
||||||
- "--libreoffice-deny-list=${LIBREOFFICE_DENY_LIST}"
|
|
||||||
- "--libreoffice-disable-routes=${LIBREOFFICE_DISABLE_ROUTES}"
|
- "--libreoffice-disable-routes=${LIBREOFFICE_DISABLE_ROUTES}"
|
||||||
- "--log-level=${LOG_LEVEL}"
|
- "--log-level=${LOG_LEVEL}"
|
||||||
- "--log-fields-prefix=${LOG_FIELDS_PREFIX}"
|
- "--log-fields-prefix=${LOG_FIELDS_PREFIX}"
|
||||||
@@ -72,23 +68,14 @@ services:
|
|||||||
- "--pdfengines-convert-engines=${PDFENGINES_CONVERT_ENGINES}"
|
- "--pdfengines-convert-engines=${PDFENGINES_CONVERT_ENGINES}"
|
||||||
- "--pdfengines-read-metadata-engines=${PDFENGINES_READ_METADATA_ENGINES}"
|
- "--pdfengines-read-metadata-engines=${PDFENGINES_READ_METADATA_ENGINES}"
|
||||||
- "--pdfengines-write-metadata-engines=${PDFENGINES_WRITE_METADATA_ENGINES}"
|
- "--pdfengines-write-metadata-engines=${PDFENGINES_WRITE_METADATA_ENGINES}"
|
||||||
- "--pdfengines-read-bookmarks-engines=${PDFENGINES_READ_BOOKMARKS_ENGINES}"
|
|
||||||
- "--pdfengines-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-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-disable-routes=${PDFENGINES_DISABLE_ROUTES}"
|
||||||
- "--prometheus-namespace=${PROMETHEUS_NAMESPACE}"
|
- "--pdfengines-add-attachments-engines=${PDFENGINES_ADD_ATTACHMENTS_ENGINES}"
|
||||||
- "--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-enable-sync-mode=${WEBHOOK_ENABLE_SYNC_MODE}"
|
||||||
- "--webhook-allow-list=${WEBHOOK_ALLOW_LIST}"
|
- "--webhook-allow-list=${WEBHOOK_ALLOW_LIST}"
|
||||||
- "--webhook-deny-list=${WEBHOOK_DENY_LIST}"
|
- "--webhook-deny-list=${WEBHOOK_DENY_LIST}"
|
||||||
|
- "--webhook-error-allow-list=${WEBHOOK_ERROR_ALLOW_LIST}"
|
||||||
|
- "--webhook-error-deny-list=${WEBHOOK_ERROR_DENY_LIST}"
|
||||||
- "--webhook-max-retry=${WEBHOOK_MAX_RETRY}"
|
- "--webhook-max-retry=${WEBHOOK_MAX_RETRY}"
|
||||||
- "--webhook-retry-min-wait=${WEBHOOK_RETRY_MIN_WAIT}"
|
- "--webhook-retry-min-wait=${WEBHOOK_RETRY_MIN_WAIT}"
|
||||||
- "--webhook-retry-max-wait=${WEBHOOK_RETRY_MAX_WAIT}"
|
- "--webhook-retry-max-wait=${WEBHOOK_RETRY_MAX_WAIT}"
|
||||||
@@ -103,20 +90,14 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "4317:4317" # OTLP gRPC receiver
|
- "4317:4317" # OTLP gRPC receiver
|
||||||
depends_on:
|
depends_on:
|
||||||
openobserve:
|
- openobserve
|
||||||
condition: service_started
|
|
||||||
restart: on-failure
|
|
||||||
|
|
||||||
openobserve:
|
openobserve:
|
||||||
image: public.ecr.aws/zinclabs/openobserve:latest
|
image: public.ecr.aws/zinclabs/openobserve:latest
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "5080:5080"
|
- "5080:5080"
|
||||||
- "5081:5081"
|
- "5081:5081" # OTLP gRPC ingestion
|
||||||
environment:
|
environment:
|
||||||
ZO_ROOT_USER_EMAIL: telemetry@gotenberg.dev
|
ZO_ROOT_USER_EMAIL: telemetry@gotenberg.dev
|
||||||
ZO_ROOT_USER_PASSWORD: telemetry
|
ZO_ROOT_USER_PASSWORD: telemetry
|
||||||
|
|
||||||
networks:
|
|
||||||
default:
|
|
||||||
enable_ipv6: false
|
|
||||||
|
|||||||
105
go.mod
105
go.mod
@@ -1,39 +1,41 @@
|
|||||||
module github.com/gotenberg/gotenberg/v8
|
module github.com/gotenberg/gotenberg/v8
|
||||||
|
|
||||||
go 1.26.2
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alexliesenfeld/health v0.8.1
|
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/cdproto v0.0.0-20250803210736-d308e07a266d
|
||||||
github.com/chromedp/chromedp v0.14.2
|
github.com/chromedp/chromedp v0.14.2
|
||||||
github.com/cucumber/godog v0.15.1
|
github.com/cucumber/godog v0.15.1
|
||||||
github.com/dlclark/regexp2 v1.12.0
|
github.com/dlclark/regexp2 v1.11.5
|
||||||
github.com/gomarkdown/markdown v0.0.0-20260412113850-134a5b2cce7f
|
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/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/hashicorp/go-retryablehttp v0.7.8
|
github.com/hashicorp/go-retryablehttp v0.7.8
|
||||||
github.com/labstack/echo/v4 v4.15.1
|
github.com/labstack/echo/v4 v4.15.1
|
||||||
github.com/labstack/gommon v0.5.0
|
github.com/labstack/gommon v0.4.2
|
||||||
github.com/mholt/archives v0.1.5
|
github.com/mholt/archives v0.1.5
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27
|
github.com/microcosm-cc/bluemonday v1.0.27
|
||||||
github.com/moby/moby/api v1.54.1
|
github.com/shirou/gopsutil/v4 v4.26.1
|
||||||
github.com/prometheus/client_golang v1.23.2
|
|
||||||
github.com/shirou/gopsutil/v4 v4.26.3
|
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/testcontainers/testcontainers-go v0.42.0
|
github.com/testcontainers/testcontainers-go v0.40.0
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0
|
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0
|
||||||
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0
|
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0
|
||||||
go.opentelemetry.io/otel v1.43.0
|
go.opentelemetry.io/otel v1.42.0
|
||||||
go.opentelemetry.io/otel/log v0.19.0
|
go.opentelemetry.io/otel/log v0.18.0
|
||||||
go.opentelemetry.io/otel/metric v1.43.0
|
go.opentelemetry.io/otel/metric v1.42.0
|
||||||
go.opentelemetry.io/otel/sdk v1.43.0
|
go.opentelemetry.io/otel/sdk v1.42.0
|
||||||
go.opentelemetry.io/otel/sdk/log v0.19.0
|
go.opentelemetry.io/otel/sdk/log v0.18.0
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0
|
go.opentelemetry.io/otel/sdk/metric v1.42.0
|
||||||
go.opentelemetry.io/otel/trace v1.43.0
|
go.opentelemetry.io/otel/trace v1.42.0
|
||||||
golang.org/x/net v0.53.0
|
go.uber.org/multierr v1.11.0
|
||||||
golang.org/x/sync v0.20.0
|
golang.org/x/net v0.51.0
|
||||||
golang.org/x/term v0.42.0
|
golang.org/x/sync v0.19.0
|
||||||
golang.org/x/text v0.36.0
|
golang.org/x/term v0.40.0
|
||||||
|
golang.org/x/text v0.34.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -41,7 +43,7 @@ require (
|
|||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/STARRY-S/zip v0.2.3 // indirect
|
github.com/STARRY-S/zip v0.2.3 // indirect
|
||||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bodgit/plumbing v1.3.0 // indirect
|
github.com/bodgit/plumbing v1.3.0 // indirect
|
||||||
@@ -60,7 +62,6 @@ require (
|
|||||||
github.com/cucumber/messages/go/v21 v21.0.1 // indirect
|
github.com/cucumber/messages/go/v21 v21.0.1 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/distribution/reference v0.6.0 // indirect
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
github.com/docker/go-connections v0.6.0 // indirect
|
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
||||||
github.com/ebitengine/purego v0.10.0 // indirect
|
github.com/ebitengine/purego v0.10.0 // indirect
|
||||||
@@ -80,29 +81,31 @@ require (
|
|||||||
github.com/hashicorp/go-memdb v1.3.5 // indirect
|
github.com/hashicorp/go-memdb v1.3.5 // indirect
|
||||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
github.com/klauspost/compress v1.18.5 // indirect
|
github.com/klauspost/compress v1.18.4 // indirect
|
||||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
|
||||||
github.com/magiconair/properties v1.8.10 // indirect
|
github.com/magiconair/properties v1.8.10 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
||||||
github.com/minio/minlz v1.1.0 // indirect
|
github.com/minio/minlz v1.0.1 // indirect
|
||||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
github.com/moby/go-archive v0.2.0 // indirect
|
github.com/moby/go-archive v0.2.0 // indirect
|
||||||
github.com/moby/moby/client v0.4.0 // indirect
|
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||||
github.com/moby/patternmatcher v0.6.1 // indirect
|
|
||||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||||
github.com/moby/sys/user v0.4.0 // indirect
|
github.com/moby/sys/user v0.4.0 // indirect
|
||||||
github.com/moby/sys/userns v0.1.0 // indirect
|
github.com/moby/sys/userns v0.1.0 // indirect
|
||||||
github.com/moby/term v0.5.2 // indirect
|
github.com/moby/term v0.5.2 // indirect
|
||||||
|
github.com/morikuni/aec v1.1.0 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nwaples/rardecode/v2 v2.2.2 // indirect
|
github.com/nwaples/rardecode/v2 v2.2.2 // indirect
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
github.com/pierrec/lz4/v4 v4.1.26 // indirect
|
github.com/pierrec/lz4/v4 v4.1.25 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
|
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.5 // indirect
|
github.com/prometheus/common v0.67.5 // indirect
|
||||||
github.com/prometheus/otlptranslator v1.0.0 // indirect
|
github.com/prometheus/otlptranslator v1.0.0 // indirect
|
||||||
@@ -117,28 +120,28 @@ require (
|
|||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 // indirect
|
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 // indirect
|
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0 // indirect
|
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 // indirect
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 // indirect
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
|
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
|
||||||
golang.org/x/crypto v0.50.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/time v0.15.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||||
google.golang.org/grpc v1.80.0 // indirect
|
google.golang.org/grpc v1.79.2 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
219
go.sum
219
go.sum
@@ -10,10 +10,12 @@ github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
|
|||||||
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
|
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
|
||||||
github.com/alexliesenfeld/health v0.8.1 h1:wdE3vt+cbJotiR8DGDBZPKHDFoJbAoWEfQTcqrmedUg=
|
github.com/alexliesenfeld/health v0.8.1 h1:wdE3vt+cbJotiR8DGDBZPKHDFoJbAoWEfQTcqrmedUg=
|
||||||
github.com/alexliesenfeld/health v0.8.1/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc=
|
github.com/alexliesenfeld/health v0.8.1/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc=
|
||||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
|
github.com/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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
|
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
|
||||||
@@ -45,8 +47,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 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
||||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||||
github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI=
|
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/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0=
|
||||||
github.com/cucumber/godog v0.15.1 h1:rb/6oHDdvVZKS66hrhpjFQFHjthFSrQBCOI1LwshNTI=
|
github.com/cucumber/godog v0.15.1 h1:rb/6oHDdvVZKS66hrhpjFQFHjthFSrQBCOI1LwshNTI=
|
||||||
@@ -59,8 +61,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
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 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
@@ -96,8 +100,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/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/gomarkdown/markdown v0.0.0-20260412113850-134a5b2cce7f h1:C5vKBogs/Qf5ID8F8XuRO8SFL+5SH7JMJrAfdLAZ2iA=
|
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
|
||||||
github.com/gomarkdown/markdown v0.0.0-20260412113850-134a5b2cce7f/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
@@ -130,8 +134,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/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/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||||
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
||||||
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||||
@@ -146,36 +150,34 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
|||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs=
|
github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs=
|
||||||
github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
|
github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
|
||||||
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
|
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||||
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
|
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
|
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
|
||||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
|
github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
|
||||||
github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4=
|
github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0=
|
github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0=
|
||||||
github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
|
github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
|
||||||
github.com/minio/minlz v1.1.0 h1:rUOGu3EP4EqJC5k3qCsIwEnZiJULKqtRyDdqbhlvMmQ=
|
github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A=
|
||||||
github.com/minio/minlz v1.1.0/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
|
github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
|
||||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
||||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
||||||
github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
|
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||||
github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||||
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
|
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||||
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
|
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
|
||||||
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 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||||
@@ -184,6 +186,8 @@ 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/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 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||||
|
github.com/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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nwaples/rardecode/v2 v2.2.2 h1:/5oL8dzYivRM/tqX9VcTSWfbpwcbwKG1QtSJr3b3KcU=
|
github.com/nwaples/rardecode/v2 v2.2.2 h1:/5oL8dzYivRM/tqX9VcTSWfbpwcbwKG1QtSJr3b3KcU=
|
||||||
@@ -194,8 +198,10 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw
|
|||||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||||
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
|
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
|
||||||
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
@@ -213,8 +219,8 @@ github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4Ul
|
|||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
|
github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc=
|
||||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||||
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
|
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
|
||||||
@@ -229,16 +235,17 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A
|
|||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
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/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
|
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
|
||||||
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
|
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 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
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 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||||
@@ -256,88 +263,90 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
|
|||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0 h1:hhPGP3zvvy1xWT9RTy970wlniSxFttBIsAK1gvMguJM=
|
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0 h1:yOYhGNPZseueTTvWp5iBD3/CthrmvayUXYEX862dDi4=
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0/go.mod h1:twJF7inoMza6kxMcF8JOdL3mPmtOZu7GEr34CUNE6Dg=
|
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0/go.mod h1:CvaNVqIfcybc+7xqZNubbE+26K6P7AKZF/l0lE2kdCk=
|
||||||
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 h1:w3zlHYETbDwXyWHZlyyR58ZC39XGi8rAhkBgUgJ9d5w=
|
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 h1:dkBzNEAIKADEaFnuESzcXvpd09vxvDZsOjx11gjUqLk=
|
||||||
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0/go.mod h1:GR/mClR2nn7vE8RLwxKjoBNg+QtgdDhRzxVa93koy5o=
|
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0/go.mod h1:Z5RIwRkZgauOIfnG5IpidvLpERjhTninpP1dTG2jTl4=
|
||||||
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0 h1:0D3GFvELGIwQGfC6agLsbrEYSGWZTRTxIXxcQUqrOuk=
|
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 h1:4fnRcNpc6YFtG3zsFw9achKn3XgmxPxuMuqIL5rE8e8=
|
||||||
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0/go.mod h1:DM2NV7Zb8CcGeVPt6glouY0FAiwZQ/iqgcWExhgWeN8=
|
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.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
|
||||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 h1:Dn8rkudDzY6KV9dr/D/bTUuWgqDf9xe0rr4G2elrn0Y=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 h1:deI9UQMoGFgrg5iLPgzueqFPHevDl+28YKfSpPTI6rY=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0/go.mod h1:gMk9F0xDgyN9M/3Ed5Y1wKcx/9mlU91NXY2SNq7RQuU=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0/go.mod h1:PFx9NgpNUKXdf7J4Q3agRxMs3Y07QhTCVipKmLsMKnU=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 h1:icqq3Z34UrEFk2u+HMhTtRsvo7Ues+eiJVjaJt62njs=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro=
|
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.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY=
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 h1:H7O6RlGOMTizyl3R08Kn5pdM06bnH8oscSj7o11tmLA=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg=
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0/go.mod h1:mBFWu/WOVDkWWsR7Tx7h6EpQB8wsv7P0Yrh0Pb7othc=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc=
|
||||||
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE=
|
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs=
|
||||||
go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo=
|
go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0 h1:GJkybS+crDMdExT/BUNCEgfrmfboztcS6PhvSo88HKM=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 h1:KJVjPD3rcPb98rIs3HznyJlrfx9ge5oJvxxlGR+P/7s=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0/go.mod h1:NuAyxRYIG2lKX3YQkB+83StTxM7s52PUUkRRiC0wnYI=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0/go.mod h1:K3kRa2ckmHWQaTWQdPRHc7qGXASuVuoEQXzrvlA98Ws=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs=
|
||||||
go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4=
|
go.opentelemetry.io/otel/log v0.18.0 h1:XgeQIIBjZZrliksMEbcwMZefoOSMI1hdjiLEiiB0bAg=
|
||||||
go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk=
|
go.opentelemetry.io/otel/log v0.18.0/go.mod h1:KEV1kad0NofR3ycsiDH4Yjcoj0+8206I6Ox2QYFSNgI=
|
||||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
|
||||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
|
||||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
|
||||||
go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko=
|
go.opentelemetry.io/otel/sdk/log v0.18.0 h1:n8OyZr7t7otkeTnPTbDNom6rW16TBYGtvyy2Gk6buQw=
|
||||||
go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg=
|
go.opentelemetry.io/otel/sdk/log v0.18.0/go.mod h1:C0+wxkTwKpOCZLrlJ3pewPiiQwpzycPI/u6W0Z9fuYk=
|
||||||
go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk=
|
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0 h1:l3mYuPsuBx6UKE47BVcPrZoZ0q/KER57vbj2qkgDLXA=
|
||||||
go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk=
|
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0/go.mod h1:7cHtiVJpZebB3wybTa4NG+FUo5NPe3PROz1FqB0+qdw=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
|
||||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
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=
|
||||||
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
|
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
|
||||||
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
|
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
|
||||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d h1:/aDRtSZJjyLQzm75d+a1wOJaqyKBMvIAfeQmoa3ORiI=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:etfGUgejTiadZAUaEP14NP97xi1RGeawqkjDARA/UOs=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
||||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
@@ -348,5 +357,3 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||||
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": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.8.1",
|
||||||
"prettier-plugin-gherkin": "^3.1.3",
|
"prettier-plugin-gherkin": "^3.1.3",
|
||||||
"prettier-plugin-sh": "^0.18.0"
|
"prettier-plugin-sh": "^0.18.0"
|
||||||
}
|
}
|
||||||
@@ -34,9 +34,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@reteps/dockerfmt": {
|
"node_modules/@reteps/dockerfmt": {
|
||||||
"version": "0.5.2",
|
"version": "0.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.3.6.tgz",
|
||||||
"integrity": "sha512-Hbr7yen4fP5TxGM54ucXa4o5NwWXatJ6Bd9I8gp0PValYbI4Rug2Gu+rVv7K7o/efQc3F5ctqWJz47rYaa8zBw==",
|
"integrity": "sha512-Tb5wIMvBf/nLejTQ61krK644/CEMB/cpiaIFXqGApfGqO3GwcR3qnI0DbmkFVCl2OyEp8LnLX3EkucoL0+tbFg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -58,9 +58,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/prettier": {
|
"node_modules/prettier": {
|
||||||
"version": "3.8.3",
|
"version": "3.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||||
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -86,13 +86,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prettier-plugin-sh": {
|
"node_modules/prettier-plugin-sh": {
|
||||||
"version": "0.18.1",
|
"version": "0.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.18.0.tgz",
|
||||||
"integrity": "sha512-uZmU22wBMevjh3rmCatNQqiEer2+5KLa0xYCBX6zQQUQkcNzVL+s6FbPKK6ZSUNUbQk6jMAcQHrYPvuL2W6ihQ==",
|
"integrity": "sha512-cW1XL27FOJQ/qGHOW6IHwdCiNWQsAgK+feA8V6+xUTaH0cD3Mh+tFAtBvEEWvuY6hTDzRV943Fzeii+qMOh7nQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@reteps/dockerfmt": "^0.5.1",
|
"@reteps/dockerfmt": "^0.3.6",
|
||||||
"sh-syntax": "^0.5.8"
|
"sh-syntax": "^0.5.8"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.8.1",
|
||||||
"prettier-plugin-gherkin": "^3.1.3",
|
"prettier-plugin-gherkin": "^3.1.3",
|
||||||
"prettier-plugin-sh": "^0.18.0"
|
"prettier-plugin-sh": "^0.18.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,13 +55,6 @@ func CommandContext(ctx context.Context, logger *slog.Logger, binPath string, ar
|
|||||||
}, nil
|
}, 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.
|
// Start starts the command but does not wait for its completion.
|
||||||
func (cmd *Cmd) Start() error {
|
func (cmd *Cmd) Start() error {
|
||||||
err := cmd.pipeOutput()
|
err := cmd.pipeOutput()
|
||||||
@@ -69,7 +62,7 @@ func (cmd *Cmd) Start() error {
|
|||||||
return fmt.Errorf("pipe unix process output: %w", err)
|
return fmt.Errorf("pipe unix process output: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.logger.DebugContext(context.Background(), fmt.Sprintf("start unix process: %s", strings.Join(cmd.process.Args, " ")))
|
cmd.logger.DebugContext(cmd.ctx, fmt.Sprintf("start unix process: %s", strings.Join(cmd.process.Args, " ")))
|
||||||
|
|
||||||
err = cmd.process.Start()
|
err = cmd.process.Start()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -116,7 +109,7 @@ func (cmd *Cmd) Exec() (int, error) {
|
|||||||
case err = <-errChan:
|
case err = <-errChan:
|
||||||
errProc := cmd.Kill()
|
errProc := cmd.Kill()
|
||||||
if errProc != nil {
|
if errProc != nil {
|
||||||
cmd.logger.ErrorContext(context.Background(), errProc.Error())
|
cmd.logger.ErrorContext(cmd.ctx, errProc.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -131,7 +124,7 @@ func (cmd *Cmd) Exec() (int, error) {
|
|||||||
case <-cmd.ctx.Done():
|
case <-cmd.ctx.Done():
|
||||||
errProc := cmd.Kill()
|
errProc := cmd.Kill()
|
||||||
if errProc != nil {
|
if errProc != nil {
|
||||||
cmd.logger.ErrorContext(context.Background(), errProc.Error())
|
cmd.logger.ErrorContext(cmd.ctx, errProc.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
return 62, fmt.Errorf("context done: %w", cmd.ctx.Err())
|
return 62, fmt.Errorf("context done: %w", cmd.ctx.Err())
|
||||||
@@ -141,7 +134,7 @@ func (cmd *Cmd) Exec() (int, error) {
|
|||||||
// pipeOutput creates logs entries according to the process stdout and stderr.
|
// pipeOutput creates logs entries according to the process stdout and stderr.
|
||||||
// It does nothing if the logging level is not debug.
|
// It does nothing if the logging level is not debug.
|
||||||
func (cmd *Cmd) pipeOutput() error {
|
func (cmd *Cmd) pipeOutput() error {
|
||||||
if !cmd.logger.Enabled(context.Background(), slog.LevelDebug) {
|
if !cmd.logger.Enabled(cmd.ctx, slog.LevelDebug) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +155,7 @@ func (cmd *Cmd) pipeOutput() error {
|
|||||||
defer func(reader io.ReadCloser) {
|
defer func(reader io.ReadCloser) {
|
||||||
err := reader.Close()
|
err := reader.Close()
|
||||||
if err != nil && !strings.Contains(err.Error(), "file already closed") {
|
if err != nil && !strings.Contains(err.Error(), "file already closed") {
|
||||||
logger.ErrorContext(context.Background(), fmt.Sprintf("close reader: %s", err))
|
logger.ErrorContext(cmd.ctx, fmt.Sprintf("close reader: %s", err))
|
||||||
}
|
}
|
||||||
}(reader)
|
}(reader)
|
||||||
|
|
||||||
@@ -170,14 +163,14 @@ func (cmd *Cmd) pipeOutput() error {
|
|||||||
line, _, err := r.ReadLine()
|
line, _, err := r.ReadLine()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err != io.EOF && !strings.Contains(err.Error(), "file already closed") {
|
if err != io.EOF && !strings.Contains(err.Error(), "file already closed") {
|
||||||
logger.ErrorContext(context.Background(), fmt.Sprintf("pipe unix process output error: %s", err))
|
logger.ErrorContext(cmd.ctx, fmt.Sprintf("pipe unix process output error: %s", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(line) != 0 {
|
if len(line) != 0 {
|
||||||
logger.DebugContext(context.Background(), string(line))
|
logger.DebugContext(cmd.ctx, string(line))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,13 +194,13 @@ func (cmd *Cmd) Kill() error {
|
|||||||
|
|
||||||
err := syscall.Kill(-cmd.process.Process.Pid, syscall.SIGKILL)
|
err := syscall.Kill(-cmd.process.Process.Pid, syscall.SIGKILL)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
cmd.logger.DebugContext(context.Background(), "unix process killed")
|
cmd.logger.DebugContext(cmd.ctx, "unix process killed")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the process does not exist anymore, the error is irrelevant.
|
// If the process does not exist anymore, the error is irrelevant.
|
||||||
if strings.Contains(err.Error(), "no such process") {
|
if strings.Contains(err.Error(), "no such process") {
|
||||||
cmd.logger.DebugContext(context.Background(), "unix process already killed")
|
cmd.logger.DebugContext(cmd.ctx, "unix process already killed")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Package gotenberg implements the core module system.
|
// Package gotenberg provides most of the logic of the module system.
|
||||||
//
|
//
|
||||||
// Significantly inspired by caddyserver/caddy, licensed under the Apache
|
// caddyserver/caddy, licensed under the Apache License 2.0, has significantly
|
||||||
// License 2.0.
|
// inspired this module system.
|
||||||
//
|
//
|
||||||
// See https://caddyserver.com/.
|
// More details are available on https://caddyserver.com/.
|
||||||
package gotenberg
|
package gotenberg
|
||||||
|
|||||||
@@ -12,57 +12,40 @@ import (
|
|||||||
// ErrFiltered happens if a value is filtered by the [FilterDeadline] function.
|
// ErrFiltered happens if a value is filtered by the [FilterDeadline] function.
|
||||||
var ErrFiltered = errors.New("value filtered")
|
var ErrFiltered = errors.New("value filtered")
|
||||||
|
|
||||||
// FilterDeadline checks if the given value is allowed and not denied according
|
// FilterDeadline checks if given value is allowed and not denied according to
|
||||||
// to regex patterns. The allowed list uses OR semantics (value must match at
|
// regex patterns. It returns a [context.DeadlineExceeded] if it takes too long
|
||||||
// least one pattern). The denied list uses OR semantics (value is denied if it
|
// to process.
|
||||||
// matches any pattern). It returns a [context.DeadlineExceeded] if it takes
|
func FilterDeadline(allowed, denied *regexp2.Regexp, s string, deadline time.Time) error {
|
||||||
// too long to process.
|
// FIXME: not ideal to compile everytime, but is there another way to create a clone?
|
||||||
func FilterDeadline(allowed, denied []*regexp2.Regexp, s string, deadline time.Time) error {
|
if allowed.String() != "" {
|
||||||
if len(allowed) > 0 {
|
allow := regexp2.MustCompile(allowed.String(), 0)
|
||||||
matched := false
|
allow.MatchTimeout = time.Until(deadline)
|
||||||
|
|
||||||
for _, pattern := range allowed {
|
ok, err := allow.MatchString(s)
|
||||||
// FIXME: not ideal to compile everytime, but is there another way to create a clone?
|
if err != nil {
|
||||||
clone := regexp2.MustCompile(pattern.String(), 0)
|
if time.Now().After(deadline) {
|
||||||
clone.MatchTimeout = time.Until(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 {
|
|
||||||
matched = true
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
return fmt.Errorf("'%s' cannot handle '%s': %w", allow.String(), s, err)
|
||||||
}
|
}
|
||||||
|
if !ok {
|
||||||
if !matched {
|
return fmt.Errorf("'%s' does not match the expression from the allowed list: %w", s, ErrFiltered)
|
||||||
return fmt.Errorf("'%s' does not match any expression from the allowed list: %w", s, ErrFiltered)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(denied) > 0 {
|
if denied.String() != "" {
|
||||||
for _, pattern := range denied {
|
deny := regexp2.MustCompile(denied.String(), 0)
|
||||||
clone := regexp2.MustCompile(pattern.String(), 0)
|
deny.MatchTimeout = time.Until(deadline)
|
||||||
clone.MatchTimeout = time.Until(deadline)
|
|
||||||
|
|
||||||
ok, err := clone.MatchString(s)
|
ok, err := deny.MatchString(s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if time.Now().After(deadline) {
|
if time.Now().After(deadline) {
|
||||||
return context.DeadlineExceeded
|
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) {
|
func TestFilterDeadline(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
scenario string
|
scenario string
|
||||||
allowed []*regexp2.Regexp
|
allowed *regexp2.Regexp
|
||||||
denied []*regexp2.Regexp
|
denied *regexp2.Regexp
|
||||||
s string
|
s string
|
||||||
deadline time.Time
|
deadline time.Time
|
||||||
expectError bool
|
expectError bool
|
||||||
@@ -21,17 +21,17 @@ func TestFilterDeadline(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
scenario: "DeadlineExceeded (allowed)",
|
scenario: "DeadlineExceeded (allowed)",
|
||||||
allowed: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)},
|
allowed: regexp2.MustCompile("foo", 0),
|
||||||
denied: nil,
|
denied: regexp2.MustCompile("", 0),
|
||||||
s: "foo",
|
s: "foo",
|
||||||
deadline: time.Now().Add(time.Duration(-1) * time.Hour),
|
deadline: time.Now().Add(time.Duration(-1) * time.Hour),
|
||||||
expectError: true,
|
expectError: true,
|
||||||
expectedError: context.DeadlineExceeded,
|
expectedError: context.DeadlineExceeded,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "ErrFiltered (allowed, no match)",
|
scenario: "ErrFiltered (allowed)",
|
||||||
allowed: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)},
|
allowed: regexp2.MustCompile("foo", 0),
|
||||||
denied: nil,
|
denied: regexp2.MustCompile("", 0),
|
||||||
s: "bar",
|
s: "bar",
|
||||||
deadline: time.Now().Add(time.Duration(5) * time.Second),
|
deadline: time.Now().Add(time.Duration(5) * time.Second),
|
||||||
expectError: true,
|
expectError: true,
|
||||||
@@ -39,8 +39,8 @@ func TestFilterDeadline(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "DeadlineExceeded (denied)",
|
scenario: "DeadlineExceeded (denied)",
|
||||||
allowed: nil,
|
allowed: regexp2.MustCompile("", 0),
|
||||||
denied: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)},
|
denied: regexp2.MustCompile("foo", 0),
|
||||||
s: "foo",
|
s: "foo",
|
||||||
deadline: time.Now().Add(time.Duration(-1) * time.Hour),
|
deadline: time.Now().Add(time.Duration(-1) * time.Hour),
|
||||||
expectError: true,
|
expectError: true,
|
||||||
@@ -48,55 +48,21 @@ func TestFilterDeadline(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "ErrFiltered (denied)",
|
scenario: "ErrFiltered (denied)",
|
||||||
allowed: nil,
|
allowed: regexp2.MustCompile("", 0),
|
||||||
denied: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)},
|
denied: regexp2.MustCompile("foo", 0),
|
||||||
s: "foo",
|
s: "foo",
|
||||||
deadline: time.Now().Add(time.Duration(5) * time.Second),
|
deadline: time.Now().Add(time.Duration(5) * time.Second),
|
||||||
expectError: true,
|
expectError: true,
|
||||||
expectedError: ErrFiltered,
|
expectedError: ErrFiltered,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "success (empty lists)",
|
scenario: "success",
|
||||||
allowed: nil,
|
allowed: regexp2.MustCompile("", 0),
|
||||||
denied: nil,
|
denied: regexp2.MustCompile("", 0),
|
||||||
s: "foo",
|
s: "foo",
|
||||||
deadline: time.Now().Add(time.Duration(5) * time.Second),
|
deadline: time.Now().Add(time.Duration(5) * time.Second),
|
||||||
expectError: false,
|
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) {
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
err := FilterDeadline(tc.allowed, tc.denied, tc.s, tc.deadline)
|
err := FilterDeadline(tc.allowed, tc.denied, tc.s, tc.deadline)
|
||||||
|
|||||||
@@ -222,32 +222,3 @@ func (f *ParsedFlags) MustDeprecatedRegexp(deprecated string, newName string) *r
|
|||||||
|
|
||||||
return f.MustRegexp(newName)
|
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,7 +6,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dlclark/regexp2"
|
|
||||||
flag "github.com/spf13/pflag"
|
flag "github.com/spf13/pflag"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -834,120 +833,3 @@ 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 {
|
for _, substr := range includeSubstr {
|
||||||
if (strings.Contains(info.Name(), substr) || path == substr) && info.ModTime().Before(expirationTime) {
|
if (strings.Contains(info.Name(), substr) || path == substr) && info.ModTime().Before(expirationTime) {
|
||||||
err := os.RemoveAll(path) //nolint:gosec // G122: rootPath is a trusted internal working directory
|
err := os.RemoveAll(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("garbage collect '%s': %w", path, err)
|
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.DiscardHandler), tc.rootPath, tc.includeSubstr, time.Now())
|
err := GarbageCollect(context.Background(), slog.New(slog.NewJSONHandler(os.Stdout, nil)), tc.rootPath, tc.includeSubstr, time.Now())
|
||||||
|
|
||||||
if !tc.expectError && err != nil {
|
if !tc.expectError && err != nil {
|
||||||
t.Fatalf("expected no error but got: %v", err)
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
|||||||
@@ -71,17 +71,16 @@ func NewStdHandler(level slog.Level, format string, fieldsPrefix string, enableG
|
|||||||
// Configure level encoding based on format and GCP settings.
|
// Configure level encoding based on format and GCP settings.
|
||||||
if a.Key == slog.LevelKey {
|
if a.Key == slog.LevelKey {
|
||||||
l := a.Value.Any().(slog.Level)
|
l := a.Value.Any().(slog.Level)
|
||||||
switch {
|
if format == textLoggingFormat && isTerminal {
|
||||||
case format == textLoggingFormat && isTerminal:
|
|
||||||
if enableGcpFields {
|
if enableGcpFields {
|
||||||
a.Value = slog.StringValue(gcpSeverityColorEncoder(l))
|
a.Value = slog.StringValue(gcpSeverityColorEncoder(l))
|
||||||
} else {
|
} else {
|
||||||
a.Value = slog.StringValue(levelToColor(l).Add(l.String()))
|
a.Value = slog.StringValue(levelToColor(l).Add(l.String()))
|
||||||
}
|
}
|
||||||
case enableGcpFields && format != textLoggingFormat:
|
} else if enableGcpFields && format != textLoggingFormat {
|
||||||
a.Key = "severity"
|
a.Key = "severity"
|
||||||
a.Value = slog.StringValue(gcpSeverity(l))
|
a.Value = slog.StringValue(gcpSeverity(l))
|
||||||
default:
|
} else {
|
||||||
a.Value = slog.StringValue(strings.ToLower(l.String()))
|
a.Value = slog.StringValue(strings.ToLower(l.String()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// Package otel gathers initialization utilities for OpenTelemetry
|
// Package otel gathers initialization utilities for OpenTelemetry
|
||||||
// instrumentation.
|
// instrumentation.
|
||||||
//
|
//
|
||||||
// Significantly inspired by https://github.com/lucavallin/gotel.
|
// This package has been significantly inspired by
|
||||||
|
// https://github.com/lucavallin/gotel.
|
||||||
//
|
//
|
||||||
// See https://opentelemetry.io/.
|
// See: https://opentelemetry.io/.
|
||||||
package otel
|
package otel
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
package gotenberg
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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,21 +45,14 @@ func (mod *DebuggableMock) Debug() map[string]any {
|
|||||||
//
|
//
|
||||||
//nolint:dupl
|
//nolint:dupl
|
||||||
type PdfEngineMock struct {
|
type PdfEngineMock struct {
|
||||||
MergeMock func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
|
MergeMock func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
|
||||||
SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
|
SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
|
||||||
FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath string) error
|
FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath string) error
|
||||||
ConvertMock func(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
|
ConvertMock func(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
|
||||||
ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
|
ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
|
||||||
PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
|
WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
|
||||||
WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
|
EncryptMock func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
|
||||||
ReadBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error)
|
AddAttachmentsMock func(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) 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 {
|
func (engine *PdfEngineMock) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
||||||
@@ -82,44 +75,16 @@ func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *slog.Logg
|
|||||||
return engine.ReadMetadataMock(ctx, logger, inputPath)
|
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 {
|
func (engine *PdfEngineMock) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
|
||||||
return engine.WriteMetadataMock(ctx, logger, metadata, inputPath)
|
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 {
|
func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
|
||||||
return engine.EncryptMock(ctx, logger, inputPath, userPassword, ownerPassword)
|
return engine.EncryptMock(ctx, logger, inputPath, userPassword, ownerPassword)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
func (engine *PdfEngineMock) AddAttachments(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
||||||
return engine.EmbedFilesMock(ctx, logger, filePaths, inputPath)
|
return engine.AddAttachmentsMock(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.
|
// PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface.
|
||||||
@@ -157,8 +122,8 @@ type ProcessSupervisorMock struct {
|
|||||||
HealthyMock func() bool
|
HealthyMock func() bool
|
||||||
RunMock func(ctx context.Context, logger *slog.Logger, task func() error) error
|
RunMock func(ctx context.Context, logger *slog.Logger, task func() error) error
|
||||||
ReqQueueSizeMock func() int64
|
ReqQueueSizeMock func() int64
|
||||||
RestartsCountMock func() int64
|
|
||||||
ActiveTasksCountMock func() int64
|
ActiveTasksCountMock func() int64
|
||||||
|
RestartsCountMock func() int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ProcessSupervisorMock) Launch() error {
|
func (s *ProcessSupervisorMock) Launch() error {
|
||||||
@@ -181,21 +146,12 @@ func (s *ProcessSupervisorMock) ReqQueueSize() int64 {
|
|||||||
return s.ReqQueueSizeMock()
|
return s.ReqQueueSizeMock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ProcessSupervisorMock) RestartsCount() int64 {
|
|
||||||
return s.RestartsCountMock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ProcessSupervisorMock) ActiveTasksCount() int64 {
|
func (s *ProcessSupervisorMock) ActiveTasksCount() int64 {
|
||||||
return s.ActiveTasksCountMock()
|
return s.ActiveTasksCountMock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MetricsProviderMock is a mock for the [MetricsProvider] interface.
|
func (s *ProcessSupervisorMock) RestartsCount() int64 {
|
||||||
type MetricsProviderMock struct {
|
return s.RestartsCountMock()
|
||||||
MetricsMock func() ([]Metric, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (provider *MetricsProviderMock) Metrics() ([]Metric, error) {
|
|
||||||
return provider.MetricsMock()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MkdirAllMock is a mock for the [MkdirAll] interface.
|
// MkdirAllMock is a mock for the [MkdirAll] interface.
|
||||||
@@ -224,7 +180,6 @@ var (
|
|||||||
_ PdfEngineProvider = (*PdfEngineProviderMock)(nil)
|
_ PdfEngineProvider = (*PdfEngineProviderMock)(nil)
|
||||||
_ Process = (*ProcessMock)(nil)
|
_ Process = (*ProcessMock)(nil)
|
||||||
_ ProcessSupervisor = (*ProcessSupervisorMock)(nil)
|
_ ProcessSupervisor = (*ProcessSupervisorMock)(nil)
|
||||||
_ MetricsProvider = (*MetricsProviderMock)(nil)
|
|
||||||
_ MkdirAll = (*MkdirAllMock)(nil)
|
_ MkdirAll = (*MkdirAllMock)(nil)
|
||||||
_ PathRename = (*PathRenameMock)(nil)
|
_ PathRename = (*PathRenameMock)(nil)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,413 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
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
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,457 +0,0 @@
|
|||||||
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},
|
|
||||||
} {
|
|
||||||
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,14 +27,6 @@ var (
|
|||||||
// ErrPdfEncryptionNotSupported is returned when encryption
|
// ErrPdfEncryptionNotSupported is returned when encryption
|
||||||
// is not supported by the PDF engine.
|
// is not supported by the PDF engine.
|
||||||
ErrPdfEncryptionNotSupported = errors.New("encryption not supported")
|
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
|
// PdfEngineInvalidArgsError represents an error returned by a PDF engine when
|
||||||
@@ -56,33 +48,6 @@ func NewPdfEngineInvalidArgs(engine, msg string) error {
|
|||||||
return &PdfEngineInvalidArgsError{engine, msg}
|
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 (
|
const (
|
||||||
// SplitModeIntervals represents a mode where a PDF is split at specific
|
// SplitModeIntervals represents a mode where a PDF is split at specific
|
||||||
// intervals.
|
// intervals.
|
||||||
@@ -143,14 +108,6 @@ type PdfFormats struct {
|
|||||||
PdfUa bool
|
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
|
// PdfEngine provides an interface for operations on PDFs. Implementations
|
||||||
// can use various tools like PDFtk, or implement functionality directly in
|
// can use various tools like PDFtk, or implement functionality directly in
|
||||||
// Go.
|
// Go.
|
||||||
@@ -177,45 +134,18 @@ type PdfEngine interface {
|
|||||||
// ReadMetadata extracts the metadata of a given PDF file.
|
// ReadMetadata extracts the metadata of a given PDF file.
|
||||||
ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
|
ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
|
||||||
|
|
||||||
// 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 writes the metadata into a given PDF file.
|
||||||
WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
|
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.
|
// Encrypt adds password protection to a PDF file.
|
||||||
// The userPassword is required to open the document.
|
// The userPassword is required to open the document.
|
||||||
// The ownerPassword provides full access to the document.
|
// The ownerPassword provides full access to the document.
|
||||||
// If the ownerPassword is empty, it defaults to the userPassword.
|
// If the ownerPassword is empty, it defaults to the userPassword.
|
||||||
Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
|
Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
|
||||||
|
|
||||||
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
|
// AddAttachments adds attachments into a PDF. All files are attached as
|
||||||
// without modifying the main PDF content.
|
// file attachments without modifying the main PDF content.
|
||||||
// TODO: attachments instead? Rename the route?
|
AddAttachments(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
|
||||||
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].
|
// PdfEngineProvider offers an interface to instantiate a [PdfEngine].
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// Package semconv contains utilities not yet exposed in the OpenTelemetry Go
|
// Package semconv is a copy/paste of utilities that are currently not exposed
|
||||||
// SDK. Remove this package once an official API exists.
|
// in the OpenTelemery Go SDK.
|
||||||
//
|
//
|
||||||
// See https://github.com/open-telemetry/opentelemetry-go-contrib/issues/4580.
|
// This package MUST be removed once an "official" API is provided.
|
||||||
|
//
|
||||||
|
// See: https://github.com/open-telemetry/opentelemetry-go-contrib/issues/4580.
|
||||||
package semconv
|
package semconv
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrProcessAlreadyRestarting happens if the [ProcessSupervisor] is trying
|
// ErrProcessAlreadyRestarting happens if the [ProcessSupervisor] is trying
|
||||||
@@ -54,9 +53,9 @@ type ProcessSupervisor interface {
|
|||||||
|
|
||||||
// Healthy checks and returns the health status of the managed [Process].
|
// Healthy checks and returns the health status of the managed [Process].
|
||||||
//
|
//
|
||||||
// A non-started process is considered healthy (startup is deferred until
|
// If the process has not been started or is restarting, it is considered
|
||||||
// the first request). Returns false if the process is currently restarting
|
// healthy and true is returned. Otherwise, it returns the health status of
|
||||||
// or is reported unhealthy by the underlying [Process].
|
// the actual process.
|
||||||
Healthy() bool
|
Healthy() bool
|
||||||
|
|
||||||
// Run executes a provided task while managing the state of the [Process].
|
// Run executes a provided task while managing the state of the [Process].
|
||||||
@@ -71,54 +70,44 @@ type ProcessSupervisor interface {
|
|||||||
// ReqQueueSize returns the current size of the request queue.
|
// ReqQueueSize returns the current size of the request queue.
|
||||||
ReqQueueSize() int64
|
ReqQueueSize() int64
|
||||||
|
|
||||||
// RestartsCount returns the current number of restart.
|
|
||||||
RestartsCount() int64
|
|
||||||
|
|
||||||
// ActiveTasksCount returns the current number of active tasks.
|
// ActiveTasksCount returns the current number of active tasks.
|
||||||
ActiveTasksCount() int64
|
ActiveTasksCount() int64
|
||||||
|
|
||||||
|
// RestartsCount returns the current number of restart.
|
||||||
|
RestartsCount() int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type processSupervisor struct {
|
type processSupervisor struct {
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
process Process
|
process Process
|
||||||
maxReqLimit int64
|
maxReqLimit int64
|
||||||
maxQueueSize int64
|
maxQueueSize int64
|
||||||
maxConcurrency int64
|
maxConcurrency int64
|
||||||
semaphore chan struct{}
|
semaphore chan struct{}
|
||||||
firstStart atomic.Bool
|
firstStart atomic.Bool
|
||||||
// firstStartMu serializes lazy-launch attempts so concurrent callers do
|
firstStartOnce sync.Once
|
||||||
// not all spawn Launch() simultaneously. Using a mutex (instead of
|
firstStartErr error
|
||||||
// sync.Once) lets a failed launch be retried by the next caller, since a
|
reqCounter atomic.Int64
|
||||||
// transient failure (such as a cold-start timeout) must not poison the
|
reqQueueSize atomic.Int64
|
||||||
// supervisor for the rest of the container's lifetime. See
|
restartsCounter atomic.Int64
|
||||||
// https://github.com/gotenberg/gotenberg/issues/1538.
|
isRestarting atomic.Bool
|
||||||
firstStartMu sync.Mutex
|
activeTasks atomic.Int64
|
||||||
reqCounter atomic.Int64
|
restartMutex sync.Mutex
|
||||||
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
|
|
||||||
idleMu sync.Mutex // protects idleStopChan
|
|
||||||
idleStopChan chan struct{} // signal to stop the idle ticker goroutine
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProcessSupervisor initializes a new [ProcessSupervisor].
|
// NewProcessSupervisor initializes a new [ProcessSupervisor].
|
||||||
func NewProcessSupervisor(logger *slog.Logger, process Process, maxReqLimit, maxQueueSize, maxConcurrency int64, idleShutdownTimeout time.Duration) ProcessSupervisor {
|
func NewProcessSupervisor(logger *slog.Logger, process Process, maxReqLimit, maxQueueSize, maxConcurrency int64) ProcessSupervisor {
|
||||||
if maxConcurrency < 1 {
|
if maxConcurrency < 1 {
|
||||||
maxConcurrency = 1
|
maxConcurrency = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
b := &processSupervisor{
|
b := &processSupervisor{
|
||||||
logger: logger,
|
logger: logger,
|
||||||
process: process,
|
process: process,
|
||||||
semaphore: make(chan struct{}, maxConcurrency),
|
semaphore: make(chan struct{}, maxConcurrency),
|
||||||
maxReqLimit: maxReqLimit,
|
maxReqLimit: maxReqLimit,
|
||||||
maxQueueSize: maxQueueSize,
|
maxQueueSize: maxQueueSize,
|
||||||
maxConcurrency: maxConcurrency,
|
maxConcurrency: maxConcurrency,
|
||||||
idleShutdownTimeout: idleShutdownTimeout,
|
|
||||||
}
|
}
|
||||||
b.reqCounter.Store(0)
|
b.reqCounter.Store(0)
|
||||||
b.reqQueueSize.Store(0)
|
b.reqQueueSize.Store(0)
|
||||||
@@ -137,12 +126,6 @@ func (s *processSupervisor) Launch() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.firstStart.Store(true)
|
s.firstStart.Store(true)
|
||||||
|
|
||||||
if s.idleShutdownTimeout > 0 {
|
|
||||||
s.lastActivity.Store(time.Now().UnixNano())
|
|
||||||
s.startIdleTicker()
|
|
||||||
}
|
|
||||||
|
|
||||||
s.logger.DebugContext(context.Background(), "process successfully started")
|
s.logger.DebugContext(context.Background(), "process successfully started")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -150,9 +133,6 @@ func (s *processSupervisor) Launch() error {
|
|||||||
|
|
||||||
func (s *processSupervisor) Shutdown() error {
|
func (s *processSupervisor) Shutdown() error {
|
||||||
s.logger.DebugContext(context.Background(), "shutdown process")
|
s.logger.DebugContext(context.Background(), "shutdown process")
|
||||||
|
|
||||||
s.stopIdleTicker()
|
|
||||||
|
|
||||||
err := s.process.Stop(s.logger)
|
err := s.process.Stop(s.logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("shutdown process: %w", err)
|
return fmt.Errorf("shutdown process: %w", err)
|
||||||
@@ -168,9 +148,8 @@ func (s *processSupervisor) restart() error {
|
|||||||
|
|
||||||
err := s.Shutdown()
|
err := s.Shutdown()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Not necessarily critical — chances are the process is already stopped,
|
// No big deal? Chances are it's already stopped.
|
||||||
// but worth flagging in case it indicates a real issue.
|
s.logger.DebugContext(context.Background(), fmt.Sprintf("stop process before restart: %s", err))
|
||||||
s.logger.WarnContext(context.Background(), fmt.Sprintf("stop process before restart: %s", err))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.Launch()
|
err = s.Launch()
|
||||||
@@ -187,84 +166,117 @@ func (s *processSupervisor) restart() error {
|
|||||||
|
|
||||||
func (s *processSupervisor) Healthy() bool {
|
func (s *processSupervisor) Healthy() bool {
|
||||||
if !s.firstStart.Load() {
|
if !s.firstStart.Load() {
|
||||||
// A non-started process is considered healthy: Gotenberg defers
|
// A non-started process is always healthy.
|
||||||
// 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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.isRestarting.Load() {
|
if s.isRestarting.Load() {
|
||||||
// A restarting process is not yet healthy — this gives load balancers
|
// A restarting process is always healthy.
|
||||||
// honest information so they can avoid routing traffic to this node.
|
return true
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.process.Healthy(s.logger)
|
return s.process.Healthy(s.logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *processSupervisor) Run(ctx context.Context, logger *slog.Logger, task func() error) error {
|
func (s *processSupervisor) Run(ctx context.Context, logger *slog.Logger, task func() error) error {
|
||||||
// Atomically check and increment the queue size to avoid the TOCTOU race
|
// A user reported a potential issue:
|
||||||
// originally reported in https://github.com/gotenberg/gotenberg/issues/951.
|
//
|
||||||
for {
|
// "Although the counting operation is atomic, nothing prevent 2 concurrent
|
||||||
current := s.reqQueueSize.Load()
|
// goroutines to retrieve the same 'currentQueueSize' and to compare its
|
||||||
if s.maxQueueSize > 0 && current >= s.maxQueueSize {
|
// value against the max limit. Then, resulting queue size would be 1 above
|
||||||
return ErrMaximumQueueSizeExceeded
|
// the allowed limit."
|
||||||
}
|
//
|
||||||
if s.reqQueueSize.CompareAndSwap(current, current+1) {
|
// However, he was unable to actually trigger this issue, even when sending
|
||||||
break
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrement when Run() returns, regardless of which path is taken
|
s.reqQueueSize.Add(1)
|
||||||
// (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 {
|
for {
|
||||||
err := func() error {
|
err := func() error {
|
||||||
if err := s.acquireSlot(ctx, logger); err != nil {
|
select {
|
||||||
return err
|
case s.semaphore <- struct{}{}:
|
||||||
}
|
logger.DebugContext(ctx, "process lock acquired")
|
||||||
|
|
||||||
s.reqCounter.Add(1)
|
// If a restart drain is in progress, release the slot
|
||||||
s.activeTasks.Add(1)
|
// immediately so the drain can acquire it instead.
|
||||||
semaphoreOwned := true
|
if s.isRestarting.Load() {
|
||||||
|
|
||||||
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
|
<-s.semaphore
|
||||||
|
return ErrProcessAlreadyRestarting
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
|
|
||||||
if err := s.ensureStarted(ctx); err != nil {
|
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.
|
||||||
return err
|
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())
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
if errors.Is(err, ErrProcessAlreadyRestarting) {
|
||||||
logger.DebugContext(ctx, "process is already restarting, trying to acquire process lock again...")
|
logger.DebugContext(ctx, "process is already restarting, trying to acquire process lock again...")
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,178 +285,6 @@ 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
|
// doRestart coordinates a process restart, draining all active concurrent
|
||||||
// tasks before stopping and restarting the process.
|
// tasks before stopping and restarting the process.
|
||||||
func (s *processSupervisor) doRestart(ctx context.Context) error {
|
func (s *processSupervisor) doRestart(ctx context.Context) error {
|
||||||
@@ -506,14 +346,14 @@ func (s *processSupervisor) ReqQueueSize() int64 {
|
|||||||
return s.reqQueueSize.Load()
|
return s.reqQueueSize.Load()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *processSupervisor) RestartsCount() int64 {
|
|
||||||
return s.restartsCounter.Load()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *processSupervisor) ActiveTasksCount() int64 {
|
func (s *processSupervisor) ActiveTasksCount() int64 {
|
||||||
return s.activeTasks.Load()
|
return s.activeTasks.Load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *processSupervisor) RestartsCount() int64 {
|
||||||
|
return s.restartsCounter.Load()
|
||||||
|
}
|
||||||
|
|
||||||
// Interface guards.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ ProcessSupervisor = (*processSupervisor)(nil)
|
_ ProcessSupervisor = (*processSupervisor)(nil)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -37,7 +38,7 @@ func TestProcessSupervisor_Launch(t *testing.T) {
|
|||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.scenario, func(t *testing.T) {
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
logger := slog.New(slog.DiscardHandler)
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
|
||||||
process := &ProcessMock{
|
process := &ProcessMock{
|
||||||
StartMock: func(logger *slog.Logger) error {
|
StartMock: func(logger *slog.Logger) error {
|
||||||
@@ -45,7 +46,7 @@ func TestProcessSupervisor_Launch(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, process, 5, 0, 1).(*processSupervisor)
|
||||||
if tc.firstStartSet {
|
if tc.firstStartSet {
|
||||||
ps.firstStart.Store(true)
|
ps.firstStart.Store(true)
|
||||||
}
|
}
|
||||||
@@ -85,7 +86,7 @@ func TestProcessSupervisor_Shutdown(t *testing.T) {
|
|||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.scenario, func(t *testing.T) {
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
logger := slog.New(slog.DiscardHandler)
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
|
||||||
process := &ProcessMock{
|
process := &ProcessMock{
|
||||||
StopMock: func(logger *slog.Logger) error {
|
StopMock: func(logger *slog.Logger) error {
|
||||||
@@ -93,7 +94,7 @@ func TestProcessSupervisor_Shutdown(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0)
|
ps := NewProcessSupervisor(logger, process, 5, 0, 1)
|
||||||
err := ps.Shutdown()
|
err := ps.Shutdown()
|
||||||
|
|
||||||
if !tc.expectError && err != nil {
|
if !tc.expectError && err != nil {
|
||||||
@@ -134,7 +135,7 @@ func TestProcessSupervisor_restart(t *testing.T) {
|
|||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.scenario, func(t *testing.T) {
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
logger := slog.New(slog.DiscardHandler)
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
|
||||||
process := &ProcessMock{
|
process := &ProcessMock{
|
||||||
StartMock: func(logger *slog.Logger) error {
|
StartMock: func(logger *slog.Logger) error {
|
||||||
@@ -145,7 +146,7 @@ func TestProcessSupervisor_restart(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, process, 5, 0, 1).(*processSupervisor)
|
||||||
|
|
||||||
err := ps.restart()
|
err := ps.restart()
|
||||||
|
|
||||||
@@ -169,15 +170,15 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
|
|||||||
expectHealthy bool
|
expectHealthy bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
scenario: "non-started process is healthy",
|
scenario: "non-started process is always healthy",
|
||||||
initiallyStarted: false,
|
initiallyStarted: false,
|
||||||
expectHealthy: true,
|
expectHealthy: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "restarting process is not healthy",
|
scenario: "restarting process is always healthy",
|
||||||
initiallyStarted: true,
|
initiallyStarted: true,
|
||||||
initiallyRestarting: true,
|
initiallyRestarting: true,
|
||||||
expectHealthy: false,
|
expectHealthy: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "process reports as healthy",
|
scenario: "process reports as healthy",
|
||||||
@@ -193,7 +194,7 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
|
|||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.scenario, func(t *testing.T) {
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
logger := slog.New(slog.DiscardHandler)
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
|
||||||
process := &ProcessMock{
|
process := &ProcessMock{
|
||||||
HealthyMock: func(logger *slog.Logger) bool {
|
HealthyMock: func(logger *slog.Logger) bool {
|
||||||
@@ -201,7 +202,7 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, process, 5, 0, 1).(*processSupervisor)
|
||||||
if tc.initiallyStarted {
|
if tc.initiallyStarted {
|
||||||
ps.firstStart.Store(true)
|
ps.firstStart.Store(true)
|
||||||
}
|
}
|
||||||
@@ -364,7 +365,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
|||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.scenario, func(t *testing.T) {
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
logger := slog.New(slog.DiscardHandler)
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
|
||||||
var startCalls, healthyCalls, stopCalls atomic.Int64
|
var startCalls, healthyCalls, stopCalls atomic.Int64
|
||||||
startCalls.Store(0)
|
startCalls.Store(0)
|
||||||
@@ -386,7 +387,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit, tc.maxQueueSize, 1, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit, tc.maxQueueSize, 1).(*processSupervisor)
|
||||||
if tc.initiallyStarted {
|
if tc.initiallyStarted {
|
||||||
ps.firstStart.Store(true)
|
ps.firstStart.Store(true)
|
||||||
}
|
}
|
||||||
@@ -470,7 +471,7 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
|
|||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.scenario, func(t *testing.T) {
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
ps := NewProcessSupervisor(slog.New(slog.DiscardHandler), new(ProcessMock), 0, 0, 1, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(slog.New(slog.NewJSONHandler(os.Stdout, nil)), new(ProcessMock), 0, 0, 1).(*processSupervisor)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||||
if tc.ctxDone {
|
if tc.ctxDone {
|
||||||
@@ -495,7 +496,7 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
|
func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
|
||||||
logger := slog.New(slog.DiscardHandler)
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
process := &ProcessMock{
|
process := &ProcessMock{
|
||||||
StartMock: func(logger *slog.Logger) error {
|
StartMock: func(logger *slog.Logger) error {
|
||||||
return nil
|
return nil
|
||||||
@@ -504,7 +505,7 @@ func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
|
|||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, process, 0, 0, 1).(*processSupervisor)
|
||||||
|
|
||||||
// Simulating a lock.
|
// Simulating a lock.
|
||||||
ps.semaphore <- struct{}{}
|
ps.semaphore <- struct{}{}
|
||||||
@@ -551,111 +552,6 @@ 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) {
|
func TestProcessSupervisor_RestartsCount(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
scenario string
|
scenario string
|
||||||
@@ -697,7 +593,7 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
|
|||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.scenario, func(t *testing.T) {
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
logger := slog.New(slog.DiscardHandler)
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
|
||||||
process := &ProcessMock{
|
process := &ProcessMock{
|
||||||
StartMock: func(logger *slog.Logger) error {
|
StartMock: func(logger *slog.Logger) error {
|
||||||
@@ -708,7 +604,7 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, process, 0, 0, 1).(*processSupervisor)
|
||||||
ps.restartsCounter.Store(tc.initialRestartsCount)
|
ps.restartsCounter.Store(tc.initialRestartsCount)
|
||||||
|
|
||||||
for i := 0; i < tc.restartAttempts; i++ {
|
for i := 0; i < tc.restartAttempts; i++ {
|
||||||
@@ -724,7 +620,7 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
|
func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
|
||||||
logger := slog.New(slog.DiscardHandler)
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
|
||||||
var startCalls atomic.Int64
|
var startCalls atomic.Int64
|
||||||
process := &ProcessMock{
|
process := &ProcessMock{
|
||||||
@@ -741,7 +637,7 @@ func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
maxConcurrency := int64(3)
|
maxConcurrency := int64(3)
|
||||||
ps := NewProcessSupervisor(logger, process, 0, 0, maxConcurrency, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, process, 0, 0, maxConcurrency).(*processSupervisor)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -788,7 +684,7 @@ func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) {
|
func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) {
|
||||||
logger := slog.New(slog.DiscardHandler)
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
|
||||||
process := &ProcessMock{
|
process := &ProcessMock{
|
||||||
StartMock: func(logger *slog.Logger) error {
|
StartMock: func(logger *slog.Logger) error {
|
||||||
@@ -803,7 +699,7 @@ func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
maxConcurrency := int64(3)
|
maxConcurrency := int64(3)
|
||||||
ps := NewProcessSupervisor(logger, process, 3, 0, maxConcurrency, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, process, 3, 0, maxConcurrency).(*processSupervisor)
|
||||||
ps.firstStart.Store(true)
|
ps.firstStart.Store(true)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
@@ -841,150 +737,3 @@ func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) {
|
|||||||
t.Fatalf("expected 1 restart, got %d", ps.RestartsCount())
|
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,6 +11,7 @@ import (
|
|||||||
"go.opentelemetry.io/otel"
|
"go.opentelemetry.io/otel"
|
||||||
"go.opentelemetry.io/otel/metric"
|
"go.opentelemetry.io/otel/metric"
|
||||||
"go.opentelemetry.io/otel/trace"
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
"go.uber.org/multierr"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg/internal/log"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg/internal/log"
|
||||||
internalotel "github.com/gotenberg/gotenberg/v8/pkg/gotenberg/internal/otel"
|
internalotel "github.com/gotenberg/gotenberg/v8/pkg/gotenberg/internal/otel"
|
||||||
@@ -54,13 +55,13 @@ func (cfg TelemetryConfig) Validate() error {
|
|||||||
var err error
|
var err error
|
||||||
|
|
||||||
if cfg.ServiceName == "" {
|
if cfg.ServiceName == "" {
|
||||||
err = errors.Join(err,
|
err = multierr.Append(err,
|
||||||
errors.New("service name must not be empty"),
|
errors.New("service name must not be empty"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.ServiceVersion == "" {
|
if cfg.ServiceVersion == "" {
|
||||||
err = errors.Join(err,
|
err = multierr.Append(err,
|
||||||
errors.New("service version must not be empty"),
|
errors.New("service version must not be empty"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -69,7 +70,7 @@ func (cfg TelemetryConfig) Validate() error {
|
|||||||
case ErrorLoggingLevel, WarnLoggingLevel, InfoLoggingLevel, DebugLoggingLevel:
|
case ErrorLoggingLevel, WarnLoggingLevel, InfoLoggingLevel, DebugLoggingLevel:
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
err = errors.Join(
|
err = multierr.Append(
|
||||||
err,
|
err,
|
||||||
fmt.Errorf("log level must be either %s, %s, %s or %s", ErrorLoggingLevel, WarnLoggingLevel, InfoLoggingLevel, DebugLoggingLevel),
|
fmt.Errorf("log level must be either %s, %s, %s or %s", ErrorLoggingLevel, WarnLoggingLevel, InfoLoggingLevel, DebugLoggingLevel),
|
||||||
)
|
)
|
||||||
@@ -79,7 +80,7 @@ func (cfg TelemetryConfig) Validate() error {
|
|||||||
case AutoLoggingFormat, JsonLoggingFormat, TextLoggingFormat:
|
case AutoLoggingFormat, JsonLoggingFormat, TextLoggingFormat:
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
err = errors.Join(
|
err = multierr.Append(
|
||||||
err,
|
err,
|
||||||
fmt.Errorf("standard log format must be either %s, %s or %s", AutoLoggingFormat, JsonLoggingFormat, TextLoggingFormat),
|
fmt.Errorf("standard log format must be either %s, %s or %s", AutoLoggingFormat, JsonLoggingFormat, TextLoggingFormat),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
"github.com/dlclark/regexp2"
|
"github.com/dlclark/regexp2"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
flag "github.com/spf13/pflag"
|
flag "github.com/spf13/pflag"
|
||||||
|
"go.uber.org/multierr"
|
||||||
"golang.org/x/net/http2"
|
"golang.org/x/net/http2"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
|
|
||||||
@@ -26,7 +28,7 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Api is a module that provides an HTTP server. Other modules may add routes,
|
// 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 {
|
type Api struct {
|
||||||
port int
|
port int
|
||||||
bindIp string
|
bindIp string
|
||||||
@@ -36,14 +38,14 @@ type Api struct {
|
|||||||
bodyLimit int64
|
bodyLimit int64
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
rootPath string
|
rootPath string
|
||||||
correlationIdHeader string
|
|
||||||
basicAuthUsername string
|
basicAuthUsername string
|
||||||
basicAuthPassword string
|
basicAuthPassword string
|
||||||
|
correlationIdHeader string
|
||||||
downloadFromCfg downloadFromConfig
|
downloadFromCfg downloadFromConfig
|
||||||
disableHealthCheckRouteTelemetry bool
|
disableHealthCheckRouteTelemetry bool
|
||||||
disableRootRouteTelemetry bool
|
disableRootRouteTelemetry bool
|
||||||
disableDebugRouteTelemetry bool
|
|
||||||
disableVersionRouteTelemetry bool
|
disableVersionRouteTelemetry bool
|
||||||
|
disableDebugRouteTelemetry bool
|
||||||
enableDebugRoute bool
|
enableDebugRoute bool
|
||||||
|
|
||||||
routes []Route
|
routes []Route
|
||||||
@@ -57,12 +59,10 @@ type Api struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type downloadFromConfig struct {
|
type downloadFromConfig struct {
|
||||||
allowList []*regexp2.Regexp
|
allowList *regexp2.Regexp
|
||||||
denyList []*regexp2.Regexp
|
denyList *regexp2.Regexp
|
||||||
denyPrivateIPs bool
|
maxRetry int
|
||||||
denyPublicIPs bool
|
disable bool
|
||||||
maxRetry int
|
|
||||||
disable bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Router is a module interface that adds routes to the [Api].
|
// Router is a module interface that adds routes to the [Api].
|
||||||
@@ -84,8 +84,7 @@ type Route struct {
|
|||||||
// Optional.
|
// Optional.
|
||||||
IsMultipart bool
|
IsMultipart bool
|
||||||
|
|
||||||
// DisableTelemetry disables telemetry (logging, tracing, metrics) for
|
// DisableTelemetry disables the telemetry and logging for this route.
|
||||||
// this route.
|
|
||||||
// Optional.
|
// Optional.
|
||||||
DisableTelemetry bool
|
DisableTelemetry bool
|
||||||
|
|
||||||
@@ -123,29 +122,6 @@ const (
|
|||||||
|
|
||||||
// Middleware is a middleware that can be added to the [Api]'s middlewares
|
// Middleware is a middleware that can be added to the [Api]'s middlewares
|
||||||
// chain.
|
// 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 {
|
type Middleware struct {
|
||||||
// Stack tells in which stack the middleware should be located.
|
// Stack tells in which stack the middleware should be located.
|
||||||
// Default to [DefaultStack].
|
// Default to [DefaultStack].
|
||||||
@@ -195,31 +171,18 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
fs.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests")
|
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-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-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.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables")
|
||||||
fs.StringSlice("api-download-from-allow-list", []string{}, "Set the allowed URLs for the download from feature using regular expressions - supports multiple values")
|
fs.String("api-correlation-id-header", "X-Correlation-ID", "Set the header name to use to set the correlation id in the logs")
|
||||||
fs.StringSlice("api-download-from-deny-list", []string{}, "Set the denied URLs for the download from feature using regular expressions - supports multiple values")
|
fs.String("api-download-from-allow-list", "", "Set the allowed URLs for the download from feature using a regular expression")
|
||||||
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.String("api-download-from-deny-list", "", "Set the denied URLs for the download from feature using a regular expression")
|
||||||
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.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature")
|
||||||
fs.Bool("api-disable-download-from", false, "Disable the download from feature")
|
fs.Bool("api-disable-download-from", false, "Disable the download from feature")
|
||||||
fs.Bool("api-disable-health-check-route-telemetry", true, "Disable telemetry for health check route")
|
fs.Bool("api-disable-health-check-route-telemetry", false, "Disable the health check route telemetry")
|
||||||
fs.Bool("api-disable-root-route-telemetry", true, "Disable telemetry for the root route")
|
fs.Bool("api-disable-root-route-telemetry", false, "Disable the root route telemetry")
|
||||||
fs.Bool("api-disable-debug-route-telemetry", true, "Disable telemetry for the debug route")
|
fs.Bool("api-disable-version-route-telemetry", false, "Disable the version route telemetry")
|
||||||
fs.Bool("api-disable-version-route-telemetry", true, "Disable telemetry for the version route")
|
fs.Bool("api-disable-debug-route-telemetry", false, "Disable the debug route telemetry")
|
||||||
fs.Bool("api-enable-debug-route", false, "Enable the debug 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
|
return fs
|
||||||
}(),
|
}(),
|
||||||
New: func() gotenberg.Module { return new(Api) },
|
New: func() gotenberg.Module { return new(Api) },
|
||||||
@@ -237,19 +200,17 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
|||||||
a.timeout = flags.MustDuration("api-timeout")
|
a.timeout = flags.MustDuration("api-timeout")
|
||||||
a.bodyLimit = flags.MustHumanReadableBytes("api-body-limit")
|
a.bodyLimit = flags.MustHumanReadableBytes("api-body-limit")
|
||||||
a.rootPath = flags.MustString("api-root-path")
|
a.rootPath = flags.MustString("api-root-path")
|
||||||
a.correlationIdHeader = flags.MustDeprecatedString("api-trace-header", "api-correlation-id-header")
|
a.correlationIdHeader = flags.MustString("api-correlation-id-header")
|
||||||
a.downloadFromCfg = downloadFromConfig{
|
a.downloadFromCfg = downloadFromConfig{
|
||||||
allowList: flags.MustRegexpSlice("api-download-from-allow-list"),
|
allowList: flags.MustRegexp("api-download-from-allow-list"),
|
||||||
denyList: flags.MustRegexpSlice("api-download-from-deny-list"),
|
denyList: flags.MustRegexp("api-download-from-deny-list"),
|
||||||
denyPrivateIPs: flags.MustBool("api-download-from-deny-private-ips"),
|
maxRetry: flags.MustInt("api-download-from-max-retry"),
|
||||||
denyPublicIPs: flags.MustBool("api-download-from-deny-public-ips"),
|
disable: flags.MustBool("api-disable-download-from"),
|
||||||
maxRetry: flags.MustInt("api-download-from-max-retry"),
|
|
||||||
disable: flags.MustBool("api-disable-download-from"),
|
|
||||||
}
|
}
|
||||||
a.disableHealthCheckRouteTelemetry = flags.MustDeprecatedBool("api-disable-health-check-logging", "api-disable-health-check-route-telemetry")
|
a.disableHealthCheckRouteTelemetry = flags.MustBool("api-disable-health-check-route-telemetry")
|
||||||
a.disableRootRouteTelemetry = flags.MustBool("api-disable-root-route-telemetry")
|
a.disableRootRouteTelemetry = flags.MustBool("api-disable-root-route-telemetry")
|
||||||
a.disableDebugRouteTelemetry = flags.MustBool("api-disable-debug-route-telemetry")
|
|
||||||
a.disableVersionRouteTelemetry = flags.MustBool("api-disable-version-route-telemetry")
|
a.disableVersionRouteTelemetry = flags.MustBool("api-disable-version-route-telemetry")
|
||||||
|
a.disableDebugRouteTelemetry = flags.MustBool("api-disable-debug-route-telemetry")
|
||||||
a.enableDebugRoute = flags.MustBool("api-enable-debug-route")
|
a.enableDebugRoute = flags.MustBool("api-enable-debug-route")
|
||||||
|
|
||||||
// Port from env?
|
// Port from env?
|
||||||
@@ -368,39 +329,33 @@ func (a *Api) Validate() error {
|
|||||||
var err error
|
var err error
|
||||||
|
|
||||||
if a.port < 1 || a.port > 65535 {
|
if a.port < 1 || a.port > 65535 {
|
||||||
err = errors.Join(err,
|
err = multierr.Append(err,
|
||||||
errors.New("port must be more than 1 and less than 65535"),
|
errors.New("port must be more than 1 and less than 65535"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.bindIp != "" && net.ParseIP(a.bindIp) == nil {
|
if a.bindIp != "" && net.ParseIP(a.bindIp) == nil {
|
||||||
err = errors.Join(err, errors.New("IP must be a valid IP address"))
|
err = multierr.Append(err, errors.New("IP must be a valid IP address"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") {
|
if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") {
|
||||||
err = errors.Join(err,
|
err = multierr.Append(err,
|
||||||
errors.New("both TLS certificate and key files must be set"),
|
errors.New("both TLS certificate and key files must be set"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(a.rootPath, "/") {
|
if !strings.HasPrefix(a.rootPath, "/") {
|
||||||
err = errors.Join(err,
|
err = multierr.Append(err,
|
||||||
errors.New("root path must start with /"),
|
errors.New("root path must start with /"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasSuffix(a.rootPath, "/") {
|
if !strings.HasSuffix(a.rootPath, "/") {
|
||||||
err = errors.Join(err,
|
err = multierr.Append(err,
|
||||||
errors.New("root path must end with /"),
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -467,29 +422,40 @@ func (a *Api) Start() error {
|
|||||||
disableTelemetryForPaths = append(disableTelemetryForPaths, strings.TrimPrefix(route.Path, "/"))
|
disableTelemetryForPaths = append(disableTelemetryForPaths, strings.TrimPrefix(route.Path, "/"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Check if the user wishes to disable telemetry for system routes.
|
||||||
// Check if the user wishes to disable telemetry for specific routes.
|
// Note: root path will be used as prefix in the underlying middlewares.
|
||||||
if a.disableHealthCheckRouteTelemetry {
|
if a.disableHealthCheckRouteTelemetry {
|
||||||
disableTelemetryForPaths = append(disableTelemetryForPaths, "health")
|
disableTelemetryForPaths = append(disableTelemetryForPaths, "health")
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.disableRootRouteTelemetry {
|
if a.disableRootRouteTelemetry {
|
||||||
disableTelemetryForPaths = append(disableTelemetryForPaths, "")
|
disableTelemetryForPaths = append(disableTelemetryForPaths, "")
|
||||||
}
|
}
|
||||||
if a.disableDebugRouteTelemetry {
|
|
||||||
disableTelemetryForPaths = append(disableTelemetryForPaths, "debug")
|
|
||||||
}
|
|
||||||
if a.disableVersionRouteTelemetry {
|
if a.disableVersionRouteTelemetry {
|
||||||
disableTelemetryForPaths = append(disableTelemetryForPaths, "version")
|
disableTelemetryForPaths = append(disableTelemetryForPaths, "version")
|
||||||
}
|
}
|
||||||
|
|
||||||
serverName := fmt.Sprintf("%s:%d", a.bindIp, a.port)
|
if a.disableDebugRouteTelemetry {
|
||||||
|
disableTelemetryForPaths = append(disableTelemetryForPaths, "debug")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always disable telemetry for favicon.
|
||||||
|
disableTelemetryForPaths = append(disableTelemetryForPaths,
|
||||||
|
"favicon.ico",
|
||||||
|
)
|
||||||
|
|
||||||
// Add the API middlewares.
|
// Add the API middlewares.
|
||||||
|
hostname, err := os.Hostname()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get hostname: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
a.srv.Pre(
|
a.srv.Pre(
|
||||||
latencyMiddleware(),
|
latencyMiddleware(),
|
||||||
rootPathMiddleware(a.rootPath),
|
rootPathMiddleware(a.rootPath),
|
||||||
outputFilenameMiddleware(),
|
outputFilenameMiddleware(),
|
||||||
telemetryMiddleware(a.logger, serverName, a.correlationIdHeader, disableTelemetryForPaths),
|
telemetryMiddleware(a.logger, hostname, a.correlationIdHeader, disableTelemetryForPaths),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add the modules' middlewares in their respective stacks.
|
// Add the modules' middlewares in their respective stacks.
|
||||||
@@ -561,9 +527,7 @@ func (a *Api) Start() error {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Let's not forget the health check routes...
|
// Let's not forget the health check routes...
|
||||||
checks := make([]health.CheckerOption, len(a.healthChecks), len(a.healthChecks)+1)
|
checks := append(a.healthChecks, health.WithTimeout(a.timeout))
|
||||||
copy(checks, a.healthChecks)
|
|
||||||
checks = append(checks, health.WithTimeout(a.timeout))
|
|
||||||
checker := health.NewChecker(checks...)
|
checker := health.NewChecker(checks...)
|
||||||
healthCheckHandler := health.NewHandler(checker)
|
healthCheckHandler := health.NewHandler(checker)
|
||||||
|
|
||||||
@@ -611,7 +575,7 @@ func (a *Api) Start() error {
|
|||||||
eg.Go(f)
|
eg.Go(f)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := eg.Wait()
|
err = eg.Wait()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("waiting for modules readiness: %w", err)
|
return fmt.Errorf("waiting for modules readiness: %w", err)
|
||||||
}
|
}
|
||||||
@@ -629,6 +593,7 @@ func (a *Api) Start() error {
|
|||||||
}
|
}
|
||||||
if !errors.Is(err, http.ErrServerClosed) {
|
if !errors.Is(err, http.ErrServerClosed) {
|
||||||
a.logger.ErrorContext(context.Background(), err.Error())
|
a.logger.ErrorContext(context.Background(), err.Error())
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -21,14 +21,13 @@ import (
|
|||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/mholt/archives"
|
"github.com/mholt/archives"
|
||||||
"go.opentelemetry.io/otel"
|
"go.opentelemetry.io/otel"
|
||||||
"go.opentelemetry.io/otel/codes"
|
|
||||||
"go.opentelemetry.io/otel/propagation"
|
"go.opentelemetry.io/otel/propagation"
|
||||||
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
|
||||||
"go.opentelemetry.io/otel/trace"
|
"go.opentelemetry.io/otel/trace"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
"golang.org/x/text/unicode/norm"
|
"golang.org/x/text/unicode/norm"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
|
semconvutil "github.com/gotenberg/gotenberg/v8/pkg/gotenberg/semconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -43,13 +42,12 @@ var (
|
|||||||
|
|
||||||
// Context is the request context for a "multipart/form-data" request.
|
// Context is the request context for a "multipart/form-data" request.
|
||||||
type Context struct {
|
type Context struct {
|
||||||
dirPath string
|
dirPath string
|
||||||
values map[string][]string
|
values map[string][]string
|
||||||
files map[string]string
|
files map[string]string
|
||||||
filesByField map[string][]string
|
filesByField map[string][]string
|
||||||
diskToOriginal map[string]string
|
outputPaths []string
|
||||||
outputPaths []string
|
cancelled bool
|
||||||
cancelled bool
|
|
||||||
|
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
echoCtx echo.Context
|
echoCtx echo.Context
|
||||||
@@ -87,14 +85,8 @@ type downloadFrom struct {
|
|||||||
// ExtraHttpHeaders are the HTTP headers to send alongside.
|
// ExtraHttpHeaders are the HTTP headers to send alongside.
|
||||||
ExtraHttpHeaders map[string]string `json:"extraHttpHeaders"`
|
ExtraHttpHeaders map[string]string `json:"extraHttpHeaders"`
|
||||||
|
|
||||||
// Embedded routes the downloaded file as an embed. Deprecated: use
|
// Download as an attachment file.
|
||||||
// Field instead. Kept for backward compatibility.
|
Attachment bool `json:"attachment"`
|
||||||
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.
|
// newContext returns a [Context] by parsing a "multipart/form-data" request.
|
||||||
@@ -143,12 +135,12 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
|
|
||||||
err := os.RemoveAll(ctx.dirPath)
|
err := os.RemoveAll(ctx.dirPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.logger.ErrorContext(context.Background(), fmt.Sprintf("remove context's working directory: %s", err))
|
ctx.logger.ErrorContext(ctx, fmt.Sprintf("remove context's working directory: %s", err))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.logger.DebugContext(context.Background(), fmt.Sprintf("'%s' context's working directory removed", ctx.dirPath))
|
ctx.logger.DebugContext(ctx, fmt.Sprintf("'%s' context's working directory removed", ctx.dirPath))
|
||||||
ctx.cancelled = true
|
ctx.cancelled = true
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -201,7 +193,6 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
ctx.values = form.Value
|
ctx.values = form.Value
|
||||||
ctx.files = make(map[string]string)
|
ctx.files = make(map[string]string)
|
||||||
ctx.filesByField = 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
|
// First, try to download files listed in the "downloadFrom" form field, if
|
||||||
// any.
|
// any.
|
||||||
@@ -232,27 +223,15 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ipOpts := []gotenberg.DecideOption{
|
err := gotenberg.FilterDeadline(downloadFromCfg.allowList, downloadFromCfg.denyList, dl.Url, deadline)
|
||||||
gotenberg.WithDenyPrivateIPs(downloadFromCfg.denyPrivateIPs),
|
|
||||||
gotenberg.WithDenyPublicIPs(downloadFromCfg.denyPublicIPs),
|
|
||||||
}
|
|
||||||
err := gotenberg.FilterOutboundURL(ctx, dl.Url, downloadFromCfg.allowList, downloadFromCfg.denyList, deadline, ipOpts...)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("filter URL: %w", err)
|
return fmt.Errorf("filter URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
dlCtx, dlSpan := gotenberg.Tracer().Start(ctx, "GET Download From",
|
logger.DebugContext(ctx, fmt.Sprintf("download file from '%s'", dl.Url))
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
|
||||||
trace.WithAttributes(semconv.ServerAddress(dl.Url)),
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.DebugContext(dlCtx, fmt.Sprintf("download file from '%s'", dl.Url))
|
req, err := retryablehttp.NewRequestWithContext(ctx, http.MethodGet, dl.Url, nil)
|
||||||
|
|
||||||
req, err := retryablehttp.NewRequest(http.MethodGet, dl.Url, nil)
|
|
||||||
if err != 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)
|
return fmt.Errorf("create request to '%s': %w", dl.Url, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,37 +239,45 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
for key, value := range dl.ExtraHttpHeaders {
|
for key, value := range dl.ExtraHttpHeaders {
|
||||||
req.Header.Set(key, value)
|
req.Header.Set(key, value)
|
||||||
}
|
}
|
||||||
|
req.Header.Set(echoCtx.Get("correlationIdHeader").(string), echoCtx.Get("correlationId").(string))
|
||||||
|
|
||||||
// Inject OTEL trace context into outbound request.
|
// OpenTelemetry.
|
||||||
otel.GetTextMapPropagator().Inject(dlCtx, propagation.HeaderCarrier(req.Header))
|
meter := gotenberg.Meter()
|
||||||
|
semconvClient := semconvutil.NewHTTPClient(meter)
|
||||||
|
|
||||||
// Propagate correlation ID header.
|
tracer := gotenberg.Tracer()
|
||||||
if correlationIdHeader, ok := echoCtx.Get("correlationIdHeader").(string); ok {
|
traceCtx, span := tracer.Start(ctx, fmt.Sprintf("%s Download From", req.Method),
|
||||||
if correlationId, ok := echoCtx.Get("correlationId").(string); ok {
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
req.Header.Set(correlationIdHeader, correlationId)
|
trace.WithAttributes(semconvClient.RequestTraceAttrs(req.Request)...),
|
||||||
}
|
)
|
||||||
}
|
defer span.End()
|
||||||
|
|
||||||
|
otel.GetTextMapPropagator().Inject(traceCtx, propagation.HeaderCarrier(req.Header))
|
||||||
|
|
||||||
client := &retryablehttp.Client{
|
client := &retryablehttp.Client{
|
||||||
HTTPClient: gotenberg.NewOutboundHttpClient(time.Until(deadline), downloadFromCfg.allowList, downloadFromCfg.denyList, ipOpts...),
|
HTTPClient: &http.Client{
|
||||||
|
Timeout: time.Until(deadline),
|
||||||
|
},
|
||||||
RetryMax: downloadFromCfg.maxRetry,
|
RetryMax: downloadFromCfg.maxRetry,
|
||||||
RetryWaitMin: time.Duration(1) * time.Second,
|
RetryWaitMin: time.Duration(1) * time.Second,
|
||||||
RetryWaitMax: time.Until(deadline),
|
RetryWaitMax: time.Until(deadline),
|
||||||
Logger: gotenberg.NewLeveledLogger(logger),
|
Logger: gotenberg.NewLeveledLogger(logger).WithContext(ctx),
|
||||||
CheckRetry: retryablehttp.DefaultRetryPolicy,
|
CheckRetry: retryablehttp.DefaultRetryPolicy,
|
||||||
Backoff: retryablehttp.DefaultBackoff,
|
Backoff: retryablehttp.DefaultBackoff,
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
dlSpan.RecordError(err)
|
span.RecordError(err)
|
||||||
dlSpan.SetStatus(codes.Error, err.Error())
|
span.SetStatus(semconvClient.Status(0))
|
||||||
dlSpan.End()
|
|
||||||
return WrapError(
|
return WrapError(
|
||||||
fmt.Errorf("download file from to '%s': %w", dl.Url, err),
|
fmt.Errorf("download file from to '%s': %w", dl.Url, err),
|
||||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Unable to download file from '%s': %s", dl.Url, err)),
|
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Unable to download file from '%s': %s", dl.Url, err)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
span.SetAttributes(semconvClient.ResponseTraceAttrs(resp)...)
|
||||||
|
span.SetStatus(semconvClient.Status(resp.StatusCode))
|
||||||
defer func() {
|
defer func() {
|
||||||
err := resp.Body.Close()
|
err := resp.Body.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -299,24 +286,16 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
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(
|
return WrapError(
|
||||||
dlErr,
|
fmt.Errorf("download file from to '%s': got status: '%s'", dl.Url, resp.Status),
|
||||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Unable to download file from '%s': got status: '%s'", dl.Url, resp.Status)),
|
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Unable to download file from '%s': got status: '%s'", dl.Url, resp.Status)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
contentDisposition := resp.Header.Get("Content-Disposition")
|
contentDisposition := resp.Header.Get("Content-Disposition")
|
||||||
if contentDisposition == "" {
|
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(
|
return WrapError(
|
||||||
dlErr,
|
fmt.Errorf("no 'Content-Disposition' header from '%s'", dl.Url),
|
||||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("No 'Content-Disposition' header from '%s'", dl.Url)),
|
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("No 'Content-Disposition' header from '%s'", dl.Url)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -326,24 +305,16 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
// See: https://github.com/golang/go/issues/69551.
|
// See: https://github.com/golang/go/issues/69551.
|
||||||
_, params, err := mime.ParseMediaType(contentDisposition)
|
_, params, err := mime.ParseMediaType(contentDisposition)
|
||||||
if err != nil {
|
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(
|
return WrapError(
|
||||||
dlErr,
|
fmt.Errorf("parse 'Content-Disposition' header '%s' from '%s': %w", contentDisposition, dl.Url, err),
|
||||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Content-Disposition' header '%s' from '%s': %s", contentDisposition, dl.Url, err)),
|
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Content-Disposition' header '%s' from '%s': %s", contentDisposition, dl.Url, err)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
filename, ok := params["filename"]
|
filename, ok := params["filename"]
|
||||||
if !ok {
|
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(
|
return WrapError(
|
||||||
dlErr,
|
fmt.Errorf("get filename from 'Content-Disposition' header '%s' from '%s'", contentDisposition, dl.Url),
|
||||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Content-Disposition' header '%s' from '%s': no filename", contentDisposition, dl.Url)),
|
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Content-Disposition' header '%s' from '%s': no filename", contentDisposition, dl.Url)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -352,20 +323,11 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
// normalized.
|
// normalized.
|
||||||
// See: https://github.com/gotenberg/gotenberg/issues/662.
|
// See: https://github.com/gotenberg/gotenberg/issues/662.
|
||||||
filename = norm.NFC.String(filepath.Base(filename))
|
filename = norm.NFC.String(filepath.Base(filename))
|
||||||
|
path := fmt.Sprintf("%s/%s", dirPath, 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)
|
out, err := os.Create(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
dlErr := fmt.Errorf("create local file: %w", err)
|
return fmt.Errorf("create local file: %w", err)
|
||||||
dlSpan.RecordError(dlErr)
|
|
||||||
dlSpan.SetStatus(codes.Error, dlErr.Error())
|
|
||||||
dlSpan.End()
|
|
||||||
return dlErr
|
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
err := out.Close()
|
err := out.Close()
|
||||||
@@ -379,27 +341,12 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
|
|
||||||
_, err = io.Copy(out, reader)
|
_, err = io.Copy(out, reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
dlErr := fmt.Errorf("copy downloaded file from '%s' to local file: %w", dl.Url, err)
|
return 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dlSpan.SetStatus(codes.Ok, "")
|
|
||||||
dlSpan.End()
|
|
||||||
|
|
||||||
ctx.files[filename] = path
|
ctx.files[filename] = path
|
||||||
ctx.diskToOriginal[path] = filename
|
if dl.Attachment {
|
||||||
|
ctx.filesByField[AttachmentsFormField] = append(ctx.filesByField[AttachmentsFormField], path)
|
||||||
// Route the downloaded file to the appropriate field bucket.
|
|
||||||
switch {
|
|
||||||
case dl.Field == "embedded" || dl.Embedded:
|
|
||||||
ctx.filesByField[EmbedsFormField] = append(ctx.filesByField[EmbedsFormField], path)
|
|
||||||
case dl.Field == "watermark":
|
|
||||||
ctx.filesByField[WatermarkFormField] = append(ctx.filesByField[WatermarkFormField], path)
|
|
||||||
case dl.Field == "stamp":
|
|
||||||
ctx.filesByField[StampFormField] = append(ctx.filesByField[StampFormField], path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -421,7 +368,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
defer func() {
|
defer func() {
|
||||||
err := in.Close()
|
err := in.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorContext(context.Background(), fmt.Sprintf("close file header: %s", err))
|
logger.ErrorContext(ctx, fmt.Sprintf("close file header: %s", err))
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -432,12 +379,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
// normalized.
|
// normalized.
|
||||||
// See: https://github.com/gotenberg/gotenberg/issues/662.
|
// See: https://github.com/gotenberg/gotenberg/issues/662.
|
||||||
filename := norm.NFC.String(filepath.Base(fh.Filename))
|
filename := norm.NFC.String(filepath.Base(fh.Filename))
|
||||||
|
path := fmt.Sprintf("%s/%s", ctx.dirPath, 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)
|
out, err := os.Create(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -446,7 +388,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
defer func() {
|
defer func() {
|
||||||
err := out.Close()
|
err := out.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorContext(context.Background(), fmt.Sprintf("close local file: %s", err))
|
logger.ErrorContext(ctx, fmt.Sprintf("close local file: %s", err))
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -456,7 +398,6 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.files[filename] = path
|
ctx.files[filename] = path
|
||||||
ctx.diskToOriginal[path] = filename
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -475,23 +416,6 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 fields: %+v", ctx.values))
|
||||||
ctx.Log().DebugContext(ctx, fmt.Sprintf("form files: %+v", ctx.files))
|
ctx.Log().DebugContext(ctx, fmt.Sprintf("form files: %+v", ctx.files))
|
||||||
ctx.Log().DebugContext(ctx, fmt.Sprintf("form files by field: %+v", ctx.filesByField))
|
ctx.Log().DebugContext(ctx, fmt.Sprintf("form files by field: %+v", ctx.filesByField))
|
||||||
@@ -508,34 +432,13 @@ func (ctx *Context) Request() *http.Request {
|
|||||||
// FormData return a [FormData].
|
// FormData return a [FormData].
|
||||||
func (ctx *Context) FormData() *FormData {
|
func (ctx *Context) FormData() *FormData {
|
||||||
return &FormData{
|
return &FormData{
|
||||||
values: ctx.values,
|
values: ctx.values,
|
||||||
files: ctx.files,
|
files: ctx.files,
|
||||||
filesByField: ctx.filesByField,
|
filesByField: ctx.filesByField,
|
||||||
diskToOriginal: ctx.diskToOriginal,
|
errors: nil,
|
||||||
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.
|
// GeneratePath generates a path within the context's working directory.
|
||||||
// It generates a new UUID-based filename. It does not create a file.
|
// It generates a new UUID-based filename. It does not create a file.
|
||||||
func (ctx *Context) GeneratePath(extension string) string {
|
func (ctx *Context) GeneratePath(extension string) string {
|
||||||
@@ -543,14 +446,10 @@ func (ctx *Context) GeneratePath(extension string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GeneratePathFromFilename generates a path within the context's working
|
// GeneratePathFromFilename generates a path within the context's working
|
||||||
// directory. It uses a UUID-based name on disk to avoid filesystem NAME_MAX
|
// directory, using the given filename (with extension). It does not create
|
||||||
// limits but registers the given filename so that [Context.OriginalFilename]
|
// a file.
|
||||||
// can resolve it. It does not create a file.
|
|
||||||
func (ctx *Context) GeneratePathFromFilename(filename string) string {
|
func (ctx *Context) GeneratePathFromFilename(filename string) string {
|
||||||
safeName := uuid.New().String() + filepath.Ext(filename)
|
return fmt.Sprintf("%s/%s", ctx.dirPath, filename)
|
||||||
path := fmt.Sprintf("%s/%s", ctx.dirPath, safeName)
|
|
||||||
ctx.diskToOriginal[path] = filename
|
|
||||||
return path
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateSubDirectory creates a subdirectory within the context's working
|
// CreateSubDirectory creates a subdirectory within the context's working
|
||||||
@@ -617,7 +516,7 @@ func (ctx *Context) BuildOutputFile() (string, error) {
|
|||||||
filesInfo, err := archives.FilesFromDisk(ctx.Context, nil, func() map[string]string {
|
filesInfo, err := archives.FilesFromDisk(ctx.Context, nil, func() map[string]string {
|
||||||
f := make(map[string]string)
|
f := make(map[string]string)
|
||||||
for _, outputPath := range ctx.outputPaths {
|
for _, outputPath := range ctx.outputPaths {
|
||||||
f[outputPath] = ctx.OriginalFilename(outputPath)
|
f[outputPath] = ""
|
||||||
}
|
}
|
||||||
return f
|
return f
|
||||||
}())
|
}())
|
||||||
@@ -653,7 +552,7 @@ func (ctx *Context) OutputFilename(outputPath string) string {
|
|||||||
filename := ctx.echoCtx.Get("outputFilename").(string)
|
filename := ctx.echoCtx.Get("outputFilename").(string)
|
||||||
|
|
||||||
if filename == "" {
|
if filename == "" {
|
||||||
return ctx.OriginalFilename(outputPath)
|
return filepath.Base(outputPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))
|
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ func TestNewContext_Cancellation(t *testing.T) {
|
|||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
c := e.NewContext(req, rec)
|
c := e.NewContext(req, rec)
|
||||||
|
|
||||||
logger := slog.New(slog.DiscardHandler)
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
||||||
timeout := time.Duration(10) * time.Second
|
timeout := time.Duration(10) * time.Second
|
||||||
downloadFromCfg := downloadFromConfig{
|
downloadFromCfg := downloadFromConfig{
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
// Package api implements the HTTP server module. Other modules register
|
// Package api provides a module, which is an HTTP server. Other modules may
|
||||||
// multipart/form data routes, middlewares, and health checks through it.
|
// add multipart/form-data routes, middlewares, and health checks.
|
||||||
package api
|
package api
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -14,18 +12,14 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/multierr"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// AttachmentsFormField represents the form field name for attaching files.
|
||||||
const (
|
const (
|
||||||
// EmbedsFormField represents the form field name for embedding files.
|
AttachmentsFormField string = "attachments"
|
||||||
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
|
// FormData is a helper for validating and hydrating values from a
|
||||||
@@ -33,11 +27,10 @@ const (
|
|||||||
//
|
//
|
||||||
// form := ctx.FormData()
|
// form := ctx.FormData()
|
||||||
type FormData struct {
|
type FormData struct {
|
||||||
values map[string][]string
|
values map[string][]string
|
||||||
files map[string]string
|
files map[string]string
|
||||||
filesByField map[string][]string
|
filesByField map[string][]string
|
||||||
diskToOriginal map[string]string
|
errors error
|
||||||
errors error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate returns nil or an error related to the [FormData] values, with a
|
// Validate returns nil or an error related to the [FormData] values, with a
|
||||||
@@ -372,58 +365,26 @@ func (form *FormData) Paths(extensions []string, target *[]string) *FormData {
|
|||||||
return form.paths(extensions, target)
|
return form.paths(extensions, target)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embeds binds the absolute paths of form data files that should be
|
// Attachments binds the absolute paths of form data files that should be
|
||||||
// embedded in the PDF. Only files uploaded with the "embeds" field name
|
// attached in the PDF. Only files uploaded with the "attachments" field name
|
||||||
// will be included.
|
// will be included.
|
||||||
//
|
//
|
||||||
// var embeds []string
|
// var attachments []string
|
||||||
//
|
//
|
||||||
// ctx.FormData().Embeds(&embeds)
|
// ctx.FormData().Attachments(&attachments)
|
||||||
func (form *FormData) Embeds(target *[]string) *FormData {
|
func (form *FormData) Attachments(target *[]string) *FormData {
|
||||||
if form.errors != nil {
|
if form.errors != nil {
|
||||||
return form
|
return form
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get files from the "embeds" field
|
// Get files from the "attachments" field
|
||||||
if paths, ok := form.filesByField[EmbedsFormField]; ok {
|
if paths, ok := form.filesByField[AttachmentsFormField]; ok {
|
||||||
*target = append(*target, paths...)
|
*target = append(*target, paths...)
|
||||||
}
|
}
|
||||||
|
|
||||||
return form
|
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
|
// 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
|
// list of file extensions, to a string slice variable. It populates an error
|
||||||
// if there is no file for given file extensions.
|
// if there is no file for given file extensions.
|
||||||
@@ -445,96 +406,34 @@ func (form *FormData) MandatoryPaths(extensions []string, target *[]string) *For
|
|||||||
return form
|
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
|
// paths bind the absolute paths of form data files, according to a list of
|
||||||
// file extensions, to a string slice variable.
|
// file extensions, to a string slice variable.
|
||||||
// embeds, watermark, and stamp files are excluded.
|
// attachments are excluded.
|
||||||
func (form *FormData) paths(extensions []string, target *[]string) *FormData {
|
func (form *FormData) paths(extensions []string, target *[]string) *FormData {
|
||||||
embeds, ok := form.filesByField[EmbedsFormField]
|
attachments, ok := form.filesByField[AttachmentsFormField]
|
||||||
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 {
|
for filename, path := range form.files {
|
||||||
if ok && slices.Contains(embeds, path) {
|
if ok && slices.Contains(attachments, path) {
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if wmOk && slices.Contains(watermarks, path) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if stOk && slices.Contains(stamps, path) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, ext := range extensions {
|
for _, ext := range extensions {
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/228.
|
// See https://github.com/gotenberg/gotenberg/issues/228.
|
||||||
if strings.ToLower(filepath.Ext(filename)) == ext {
|
if strings.ToLower(filepath.Ext(filename)) == ext {
|
||||||
entries = append(entries, entry{original: filename, disk: path})
|
*target = append(*target, path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/139.
|
// See https://github.com/gotenberg/gotenberg/issues/139.
|
||||||
originals := make(gotenberg.AlphanumericSort, len(entries))
|
sort.Sort(gotenberg.AlphanumericSort(*target))
|
||||||
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
|
return form
|
||||||
}
|
}
|
||||||
|
|
||||||
// append adds an error to the list of errors.
|
// append adds an error to the list of errors.
|
||||||
func (form *FormData) append(err error) {
|
func (form *FormData) append(err error) {
|
||||||
form.errors = errors.Join(form.errors, err)
|
form.errors = multierr.Append(form.errors, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// mustValue binds the target interface with a form field. If the value is
|
// 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,
|
expectCount: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "files except embeds",
|
scenario: "files except attachments",
|
||||||
form: &FormData{
|
form: &FormData{
|
||||||
files: map[string]string{
|
files: map[string]string{
|
||||||
"foo.pdf": "/foo.pdf",
|
"foo.pdf": "/foo.pdf",
|
||||||
"embed_1.pdf": "/embed_1.pdf",
|
"attachments_1.pdf": "/attachments_1.pdf",
|
||||||
"embed_2.xml": "/embed_2.xml",
|
"attachments_2.xml": "/attachments_2.xml",
|
||||||
},
|
},
|
||||||
filesByField: map[string][]string{
|
filesByField: map[string][]string{
|
||||||
"embeds": {"/embed_1.pdf", "/embed_2.xml"},
|
"attachments": {"/attachments_1.pdf", "/attachments_2.xml"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
extensions: []string{".pdf"},
|
extensions: []string{".pdf"},
|
||||||
@@ -1759,7 +1759,7 @@ func TestFormData_mustAssign(t *testing.T) {
|
|||||||
form.mustAssign("foo", "foo", &target)
|
form.mustAssign("foo", "foo", &target)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFormData_Embeds(t *testing.T) {
|
func TestFormData_Attachments(t *testing.T) {
|
||||||
expected := []string{"/bar.xml", "/baz.xml"}
|
expected := []string{"/bar.xml", "/baz.xml"}
|
||||||
|
|
||||||
var actual []string
|
var actual []string
|
||||||
@@ -1770,13 +1770,13 @@ func TestFormData_Embeds(t *testing.T) {
|
|||||||
"baz.xml": "/baz.xml",
|
"baz.xml": "/baz.xml",
|
||||||
},
|
},
|
||||||
filesByField: map[string][]string{
|
filesByField: map[string][]string{
|
||||||
"embeds": {"/bar.xml", "/baz.xml"},
|
"attachments": {"/bar.xml", "/baz.xml"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
form.Embeds(&actual)
|
form.Attachments(&actual)
|
||||||
|
|
||||||
if len(actual) != len(expected) {
|
if len(actual) != len(expected) {
|
||||||
t.Errorf("expected %d embeds but got %d", len(expected), len(actual))
|
t.Errorf("expected %d attachments but got %d", len(expected), len(actual))
|
||||||
}
|
}
|
||||||
|
|
||||||
if !reflect.DeepEqual(actual, expected) {
|
if !reflect.DeepEqual(actual, expected) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -66,15 +67,8 @@ 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"
|
return http.StatusBadRequest, "At least one PDF engine cannot process the requested metadata, while others may have failed to convert due to different issues"
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, gotenberg.ErrPdfStampSourceNotSupported) {
|
var invalidArgsError *gotenberg.PdfEngineInvalidArgsError
|
||||||
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.As(err, &invalidArgsError) {
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
return http.StatusBadRequest, invalidArgsError.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +197,7 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
|
|||||||
}
|
}
|
||||||
|
|
||||||
if skipTelemetry {
|
if skipTelemetry {
|
||||||
c.Set("logger", slog.New(slog.DiscardHandler))
|
c.Set("logger", slog.New(slog.NewJSONHandler(io.Discard, nil)))
|
||||||
|
|
||||||
err := next(c)
|
err := next(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -337,10 +331,7 @@ func basicAuthMiddleware(username, password string) echo.MiddlewareFunc {
|
|||||||
func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) 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(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
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
|
// We create a context with a timeout so that underlying processes are
|
||||||
// able to stop early and correctly handle a timeout scenario.
|
// able to stop early and correctly handle a timeout scenario.
|
||||||
@@ -398,14 +389,7 @@ func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimi
|
|||||||
func hardTimeoutMiddleware(hardTimeout time.Duration) echo.MiddlewareFunc {
|
func hardTimeoutMiddleware(hardTimeout time.Duration) echo.MiddlewareFunc {
|
||||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
// Guard the type assertion so a pooled [echo.Context] whose
|
logger := c.Get("logger").(*slog.Logger)
|
||||||
// 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
|
// Define a hard timeout if the route handler fails to timeout as
|
||||||
// expected.
|
// expected.
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
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.
|
// SetLogger sets the logger.
|
||||||
//
|
//
|
||||||
// ctx := &api.ContextMock{Context: &api.Context{}}
|
// ctx := &api.ContextMock{Context: &api.Context{}}
|
||||||
// ctx.SetLogger(slog.Default())
|
// ctx.SetLogger(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
|
||||||
func (ctx *ContextMock) SetLogger(logger *slog.Logger) {
|
func (ctx *ContextMock) SetLogger(logger *slog.Logger) {
|
||||||
ctx.logger = logger
|
ctx.logger = logger
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ import (
|
|||||||
"github.com/chromedp/chromedp"
|
"github.com/chromedp/chromedp"
|
||||||
"github.com/dlclark/regexp2"
|
"github.com/dlclark/regexp2"
|
||||||
"github.com/shirou/gopsutil/v4/process"
|
"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"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
@@ -42,10 +46,8 @@ type browserArguments struct {
|
|||||||
hyphenDataDirPath string
|
hyphenDataDirPath string
|
||||||
|
|
||||||
// Tasks specific.
|
// Tasks specific.
|
||||||
allowList []*regexp2.Regexp
|
allowList *regexp2.Regexp
|
||||||
denyList []*regexp2.Regexp
|
denyList *regexp2.Regexp
|
||||||
denyPrivateIPs bool
|
|
||||||
denyPublicIPs bool
|
|
||||||
clearCache bool
|
clearCache bool
|
||||||
clearCookies bool
|
clearCookies bool
|
||||||
disableJavaScript bool
|
disableJavaScript bool
|
||||||
@@ -59,17 +61,15 @@ type chromiumBrowser struct {
|
|||||||
ctxMu sync.RWMutex
|
ctxMu sync.RWMutex
|
||||||
isStarted atomic.Bool
|
isStarted atomic.Bool
|
||||||
|
|
||||||
arguments browserArguments
|
arguments browserArguments
|
||||||
fs *gotenberg.FileSystem
|
fs *gotenberg.FileSystem
|
||||||
pinningProxy *pinningProxy
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newChromiumBrowser(arguments browserArguments) browser {
|
func newChromiumBrowser(arguments browserArguments) browser {
|
||||||
b := &chromiumBrowser{
|
b := &chromiumBrowser{
|
||||||
initialCtx: context.Background(),
|
initialCtx: context.Background(),
|
||||||
arguments: arguments,
|
arguments: arguments,
|
||||||
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
|
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
|
||||||
pinningProxy: newPinningProxy(arguments.allowList, arguments.denyList, arguments.denyPrivateIPs, arguments.denyPublicIPs),
|
|
||||||
}
|
}
|
||||||
b.isStarted.Store(false)
|
b.isStarted.Store(false)
|
||||||
|
|
||||||
@@ -81,7 +81,10 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
|||||||
return errors.New("browser is already started")
|
return errors.New("browser is already started")
|
||||||
}
|
}
|
||||||
|
|
||||||
debug := &debugLogger{logger: logger}
|
debug := &debugLogger{
|
||||||
|
ctx: b.initialCtx,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
b.userProfileDirPath = b.fs.NewDirPath()
|
b.userProfileDirPath = b.fs.NewDirPath()
|
||||||
|
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/1293.
|
// See https://github.com/gotenberg/gotenberg/issues/1293.
|
||||||
@@ -140,25 +143,6 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
|||||||
opts = append(opts, chromedp.ProxyServer(b.arguments.proxyServer))
|
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.
|
// See https://github.com/gotenberg/gotenberg/issues/524.
|
||||||
opts = append(opts, chromedp.WSURLReadTimeout(b.arguments.wsUrlReadTimeout))
|
opts = append(opts, chromedp.WSURLReadTimeout(b.arguments.wsUrlReadTimeout))
|
||||||
|
|
||||||
@@ -222,9 +206,9 @@ func (b *chromiumBrowser) Stop(logger *slog.Logger) error {
|
|||||||
|
|
||||||
err = p.KillWithContext(killCtx)
|
err = p.KillWithContext(killCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorContext(context.Background(), fmt.Sprintf("kill process: %v", err))
|
logger.ErrorContext(killCtx, fmt.Sprintf("kill process: %v", err))
|
||||||
} else {
|
} else {
|
||||||
logger.DebugContext(context.Background(), fmt.Sprintf("Chromium process %d killed", p.Pid))
|
logger.DebugContext(killCtx, fmt.Sprintf("Chromium process %d killed", p.Pid))
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -259,15 +243,6 @@ func (b *chromiumBrowser) Stop(logger *slog.Logger) error {
|
|||||||
b.userProfileDirPath = ""
|
b.userProfileDirPath = ""
|
||||||
b.isStarted.Store(false)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,7 +273,7 @@ func (b *chromiumBrowser) Healthy(logger *slog.Logger) bool {
|
|||||||
return err
|
return err
|
||||||
}))
|
}))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorContext(context.Background(), fmt.Sprintf("browser health check failed: %s", err))
|
logger.ErrorContext(ctx, fmt.Sprintf("browser health check failed: %s", err))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,12 +343,8 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
|||||||
return errors.New("context has no deadline")
|
return errors.New("context has no deadline")
|
||||||
}
|
}
|
||||||
|
|
||||||
// We validate the "main" URL against our allowed / deny lists, and
|
// We validate the "main" URL against our allowed / deny lists.
|
||||||
// against the IP-based outbound URL guard. See [gotenberg.FilterOutboundURL].
|
err := gotenberg.FilterDeadline(b.arguments.allowList, b.arguments.denyList, url, deadline)
|
||||||
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("filter URL: %w", err)
|
return fmt.Errorf("filter URL: %w", err)
|
||||||
}
|
}
|
||||||
@@ -392,12 +363,9 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
|||||||
// the extra HTTP headers, if any.
|
// the extra HTTP headers, if any.
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/1011.
|
// See https://github.com/gotenberg/gotenberg/issues/1011.
|
||||||
listenForEventRequestPaused(taskCtx, logger, eventRequestPausedOptions{
|
listenForEventRequestPaused(taskCtx, logger, eventRequestPausedOptions{
|
||||||
allowList: b.arguments.allowList,
|
allowList: b.arguments.allowList,
|
||||||
denyList: b.arguments.denyList,
|
denyList: b.arguments.denyList,
|
||||||
denyPrivateIPs: b.arguments.denyPrivateIPs,
|
extraHttpHeaders: options.ExtraHttpHeaders,
|
||||||
denyPublicIPs: b.arguments.denyPublicIPs,
|
|
||||||
allowedFilePrefixes: options.AllowedFilePrefixes,
|
|
||||||
extraHttpHeaders: options.ExtraHttpHeaders,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -420,7 +388,6 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
|||||||
ignoreResourceHttpStatusDomains: options.IgnoreResourceHttpStatusDomains,
|
ignoreResourceHttpStatusDomains: options.IgnoreResourceHttpStatusDomains,
|
||||||
invalidResourceHttpStatusCode: &invalidResourceHttpStatusCode,
|
invalidResourceHttpStatusCode: &invalidResourceHttpStatusCode,
|
||||||
invalidResourceHttpStatusCodeMu: &invalidResourceHttpStatusCodeMu,
|
invalidResourceHttpStatusCodeMu: &invalidResourceHttpStatusCodeMu,
|
||||||
cancelOnMainPageError: taskCancel,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,45 +417,30 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
|||||||
loadingFailedMu: &loadingFailedMu,
|
loadingFailedMu: &loadingFailedMu,
|
||||||
resourceLoadingFailed: &resourceLoadingFailed,
|
resourceLoadingFailed: &resourceLoadingFailed,
|
||||||
resourceLoadingFailedMu: &resourceLoadingFailedMu,
|
resourceLoadingFailedMu: &resourceLoadingFailedMu,
|
||||||
cancelOnMainPageError: taskCancel,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
runErr := chromedp.Run(taskCtx, tasks...)
|
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"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
// Check event-driven errors first — they take priority over chromedp.Run
|
err = chromedp.Run(clientCtx, tasks...)
|
||||||
// errors because they carry the actual root cause (e.g., HTTP 500 from
|
if err != nil {
|
||||||
// the main page). When we cancel taskCtx on a main page error,
|
clientSpan.RecordError(err)
|
||||||
// chromedp.Run returns a context error that is less informative.
|
clientSpan.SetStatus(codes.Error, err.Error())
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/1021.
|
clientSpan.End()
|
||||||
invalidResourceHttpStatusCodeMu.RLock()
|
|
||||||
defer invalidResourceHttpStatusCodeMu.RUnlock()
|
|
||||||
|
|
||||||
if invalidResourceHttpStatusCode != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%v: %w", invalidResourceHttpStatusCode, ErrInvalidResourceHttpStatusCode)
|
errMessage := err.Error()
|
||||||
}
|
|
||||||
|
|
||||||
// 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)") {
|
if strings.Contains(errMessage, "Printing failed (-32000)") {
|
||||||
return ErrPrintingFailed
|
return ErrPrintingFailed
|
||||||
@@ -510,7 +462,23 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
|||||||
return ErrRpccMessageTooLarge
|
return ErrRpccMessageTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("handle tasks: %w", runErr)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/262.
|
// See https://github.com/gotenberg/gotenberg/issues/262.
|
||||||
@@ -521,6 +489,16 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
|||||||
return fmt.Errorf("%v: %w", consoleExceptions, ErrConsoleExceptions)
|
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.
|
// See https://github.com/gotenberg/gotenberg/issues/1021.
|
||||||
if options.FailOnResourceLoadingFailed {
|
if options.FailOnResourceLoadingFailed {
|
||||||
if resourceLoadingFailed != nil {
|
if resourceLoadingFailed != nil {
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ import (
|
|||||||
"go.opentelemetry.io/otel/attribute"
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.opentelemetry.io/otel/codes"
|
"go.opentelemetry.io/otel/codes"
|
||||||
"go.opentelemetry.io/otel/metric"
|
"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/gotenberg"
|
||||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||||
@@ -188,15 +186,6 @@ type Options struct {
|
|||||||
// OmitBackground hides the default white background and allows generating
|
// OmitBackground hides the default white background and allows generating
|
||||||
// PDFs with transparency.
|
// PDFs with transparency.
|
||||||
OmitBackground bool
|
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
|
// EmulatedMediaFeature gathers the available entries for emulating a media
|
||||||
@@ -294,7 +283,7 @@ type PdfOptions struct {
|
|||||||
PreferCssPageSize bool
|
PreferCssPageSize bool
|
||||||
|
|
||||||
// GenerateDocumentOutline defines whether the document outline should be
|
// GenerateDocumentOutline defines whether the document outline should be
|
||||||
// embedded into the PDF.
|
// attached into the PDF.
|
||||||
GenerateDocumentOutline bool
|
GenerateDocumentOutline bool
|
||||||
|
|
||||||
// GenerateTaggedPdf defines whether to generate tagged (accessible)
|
// GenerateTaggedPdf defines whether to generate tagged (accessible)
|
||||||
@@ -438,7 +427,6 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
fs := flag.NewFlagSet("chromium", flag.ExitOnError)
|
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-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.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.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.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")
|
fs.Duration("chromium-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for Chromium to start or restart")
|
||||||
@@ -448,22 +436,13 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
fs.Bool("chromium-allow-file-access-from-files", false, "Allow file:// URIs to read other file:// URIs")
|
fs.Bool("chromium-allow-file-access-from-files", false, "Allow file:// URIs to read other file:// URIs")
|
||||||
fs.String("chromium-host-resolver-rules", "", "Set custom mappings to the host resolver")
|
fs.String("chromium-host-resolver-rules", "", "Set custom mappings to the host resolver")
|
||||||
fs.String("chromium-proxy-server", "", "Set the outbound proxy server; this switch only affects HTTP and HTTPS requests")
|
fs.String("chromium-proxy-server", "", "Set the outbound proxy server; this switch only affects HTTP and HTTPS requests")
|
||||||
fs.StringSlice("chromium-allow-list", []string{}, "Set the allowed URLs for Chromium using regular expressions - supports multiple values")
|
fs.String("chromium-allow-list", "", "Set the allowed URLs for Chromium using a regular expression")
|
||||||
fs.StringSlice("chromium-deny-list", []string{`^file:(?!//\/tmp/).*`}, "Set the denied URLs for Chromium using regular expressions - supports multiple values")
|
fs.String("chromium-deny-list", `^file:(?!//\/tmp/).*`, "Set the denied URLs for Chromium using a regular expression")
|
||||||
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-cache", false, "Clear Chromium cache between each conversion")
|
||||||
fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion")
|
fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion")
|
||||||
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
|
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
|
||||||
fs.Bool("chromium-disable-routes", false, "Disable the routes")
|
fs.Bool("chromium-disable-routes", false, "Disable the routes")
|
||||||
|
|
||||||
// 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
|
return fs
|
||||||
}(),
|
}(),
|
||||||
New: func() gotenberg.Module { return new(Chromium) },
|
New: func() gotenberg.Module { return new(Chromium) },
|
||||||
@@ -498,10 +477,8 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
|||||||
wsUrlReadTimeout: flags.MustDuration("chromium-start-timeout"),
|
wsUrlReadTimeout: flags.MustDuration("chromium-start-timeout"),
|
||||||
hyphenDataDirPath: hyphenDataDirPath,
|
hyphenDataDirPath: hyphenDataDirPath,
|
||||||
|
|
||||||
allowList: flags.MustRegexpSlice("chromium-allow-list"),
|
allowList: flags.MustRegexp("chromium-allow-list"),
|
||||||
denyList: flags.MustRegexpSlice("chromium-deny-list"),
|
denyList: flags.MustRegexp("chromium-deny-list"),
|
||||||
denyPrivateIPs: flags.MustBool("chromium-deny-private-ips"),
|
|
||||||
denyPublicIPs: flags.MustBool("chromium-deny-public-ips"),
|
|
||||||
clearCache: flags.MustBool("chromium-clear-cache"),
|
clearCache: flags.MustBool("chromium-clear-cache"),
|
||||||
clearCookies: flags.MustBool("chromium-clear-cookies"),
|
clearCookies: flags.MustBool("chromium-clear-cookies"),
|
||||||
disableJavaScript: flags.MustBool("chromium-disable-javascript"),
|
disableJavaScript: flags.MustBool("chromium-disable-javascript"),
|
||||||
@@ -512,7 +489,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
|||||||
|
|
||||||
// Process.
|
// Process.
|
||||||
mod.browser = newChromiumBrowser(mod.args)
|
mod.browser = newChromiumBrowser(mod.args)
|
||||||
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"), mod.maxConcurrency, flags.MustDuration("chromium-idle-shutdown-timeout"))
|
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"), mod.maxConcurrency)
|
||||||
|
|
||||||
// PDF Engine.
|
// PDF Engine.
|
||||||
provider, err := ctx.Module(new(gotenberg.PdfEngineProvider))
|
provider, err := ctx.Module(new(gotenberg.PdfEngineProvider))
|
||||||
@@ -525,107 +502,104 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
|||||||
}
|
}
|
||||||
mod.engine = engine
|
mod.engine = engine
|
||||||
|
|
||||||
// Metrics.
|
// OpenTelemetry.
|
||||||
meter := gotenberg.Meter()
|
meter := gotenberg.Meter()
|
||||||
|
_, err = meter.Int64ObservableCounter(
|
||||||
// Observable gauges.
|
"chromium.process.restarts.total",
|
||||||
_, err = meter.Int64ObservableGauge(
|
metric.WithDescription("Current number of Chromium restarts."),
|
||||||
"chromium.requests.active",
|
metric.WithUnit("{restart}"),
|
||||||
metric.WithDescription("Current number of active Chromium requests"),
|
|
||||||
metric.WithUnit("{request}"),
|
|
||||||
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
|
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
|
||||||
o.Observe(mod.supervisor.ActiveTasksCount())
|
val := mod.supervisor.RestartsCount()
|
||||||
|
o.Observe(val)
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create chromium.requests.active gauge: %w", err)
|
return fmt.Errorf("create process restarts observable counter: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = meter.Int64ObservableGauge(
|
_, err = meter.Int64ObservableGauge(
|
||||||
"chromium.requests.queue_size",
|
"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.WithUnit("{request}"),
|
||||||
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
|
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
|
||||||
o.Observe(mod.supervisor.ReqQueueSize())
|
val := mod.supervisor.ReqQueueSize()
|
||||||
|
o.Observe(val)
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create chromium.requests.queue_size gauge: %w", err)
|
return fmt.Errorf("create requests queue size observable gauge: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = meter.Int64ObservableCounter(
|
_, err = meter.Int64ObservableGauge(
|
||||||
"chromium.process.restarts.total",
|
"chromium.requests.active",
|
||||||
metric.WithDescription("Current number of Chromium restarts"),
|
metric.WithDescription("Current number of Chromium conversion requests actively being processed."),
|
||||||
metric.WithUnit("{restart}"),
|
metric.WithUnit("{request}"),
|
||||||
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
|
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
|
||||||
o.Observe(mod.supervisor.RestartsCount())
|
val := mod.supervisor.ActiveTasksCount()
|
||||||
|
o.Observe(val)
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create chromium.process.restarts.total counter: %w", err)
|
return fmt.Errorf("create requests active observable gauge: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Counters.
|
|
||||||
mod.reqsCounter, err = meter.Int64Counter(
|
mod.reqsCounter, err = meter.Int64Counter(
|
||||||
"chromium.requests.total",
|
"chromium.requests.total",
|
||||||
metric.WithDescription("Total number of Chromium conversion requests"),
|
metric.WithDescription("Total number of Chromium conversion requests."),
|
||||||
metric.WithUnit("{request}"),
|
metric.WithUnit("{request}"),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create chromium.requests.total counter: %w", err)
|
return fmt.Errorf("create requests total counter: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
mod.errsCounter, err = meter.Int64Counter(
|
mod.errsCounter, err = meter.Int64Counter(
|
||||||
"chromium.errors.total",
|
"chromium.errors.total",
|
||||||
metric.WithDescription("Total number of Chromium conversion errors"),
|
metric.WithDescription("Total number of Chromium errors."),
|
||||||
metric.WithUnit("{error}"),
|
metric.WithUnit("{error}"),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create chromium.errors.total counter: %w", err)
|
return fmt.Errorf("create errors total counter: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Histograms.
|
|
||||||
durationBuckets := metric.WithExplicitBucketBoundaries(0.5, 1, 2, 5, 10, 30, 60)
|
|
||||||
|
|
||||||
mod.conversionDurationCounter, err = meter.Float64Histogram(
|
mod.conversionDurationCounter, err = meter.Float64Histogram(
|
||||||
"chromium.conversion.duration",
|
"chromium.conversion.duration",
|
||||||
metric.WithDescription("Duration of Chromium conversions"),
|
metric.WithDescription("Duration of each HTML-to-PDF conversion."),
|
||||||
metric.WithUnit("s"),
|
metric.WithUnit("s"),
|
||||||
durationBuckets,
|
metric.WithExplicitBucketBoundaries(0.5, 1, 2, 5, 10, 30, 60),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create chromium.conversion.duration histogram: %w", err)
|
return fmt.Errorf("create conversion duration histogram: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
mod.queueWaitDurationCounter, err = meter.Float64Histogram(
|
mod.queueWaitDurationCounter, err = meter.Float64Histogram(
|
||||||
"chromium.queue.wait.duration",
|
"chromium.queue.wait.duration",
|
||||||
metric.WithDescription("Duration of waiting in queue for Chromium conversions"),
|
metric.WithDescription("Time a request spends waiting in the queue before processing starts."),
|
||||||
metric.WithUnit("s"),
|
metric.WithUnit("s"),
|
||||||
durationBuckets,
|
metric.WithExplicitBucketBoundaries(0.5, 1, 2, 5, 10, 30, 60),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create chromium.queue.wait.duration histogram: %w", err)
|
return fmt.Errorf("create queue wait duration histogram: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
mod.pdfOutputSizeCounter, err = meter.Int64Histogram(
|
mod.pdfOutputSizeCounter, err = meter.Int64Histogram(
|
||||||
"chromium.pdf.output.size",
|
"chromium.pdf.output.size",
|
||||||
metric.WithDescription("Size of PDF output from Chromium conversions"),
|
metric.WithDescription("Size of the generated PDF files."),
|
||||||
metric.WithUnit("By"),
|
metric.WithUnit("By"),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create chromium.pdf.output.size histogram: %w", err)
|
return fmt.Errorf("create pdf output size histogram: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
mod.imageOutputSizeCounter, err = meter.Int64Histogram(
|
mod.imageOutputSizeCounter, err = meter.Int64Histogram(
|
||||||
"chromium.image.output.size",
|
"chromium.image.output.size",
|
||||||
metric.WithDescription("Size of image output from Chromium screenshots"),
|
metric.WithDescription("Size of the generated image files."),
|
||||||
metric.WithUnit("By"),
|
metric.WithUnit("By"),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create chromium.image.output.size histogram: %w", err)
|
return fmt.Errorf("create image output size histogram: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -707,26 +681,6 @@ func (mod *Chromium) Debug() map[string]any {
|
|||||||
return debug
|
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.
|
// Checks adds a health check that verifies if Chromium is healthy.
|
||||||
func (mod *Chromium) Checks() ([]health.CheckerOption, error) {
|
func (mod *Chromium) Checks() ([]health.CheckerOption, error) {
|
||||||
return []health.CheckerOption{
|
return []health.CheckerOption{
|
||||||
@@ -795,10 +749,9 @@ func (mod *Chromium) Routes() ([]api.Route, error) {
|
|||||||
|
|
||||||
// Pdf converts a URL to PDF.
|
// Pdf converts a URL to PDF.
|
||||||
func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
|
func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Pdf",
|
// Note: no error wrapping because it leaks on errors we want to display to
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
// the end user.
|
||||||
trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)),
|
ctx, span := gotenberg.Tracer().Start(ctx, "Chromium.Pdf")
|
||||||
)
|
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
@@ -819,15 +772,17 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
|
|||||||
status = "error"
|
status = "error"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
|
||||||
reason := "unknown"
|
reason := "unknown"
|
||||||
switch {
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
case errors.Is(err, context.DeadlineExceeded):
|
|
||||||
reason = "timeout"
|
reason = "timeout"
|
||||||
case errors.Is(err, context.Canceled):
|
} else if errors.Is(err, context.Canceled) {
|
||||||
reason = "context_cancelled"
|
reason = "context_cancelled"
|
||||||
case errors.Is(err, ErrInvalidHttpStatusCode) || errors.Is(err, ErrInvalidResourceHttpStatusCode) || errors.Is(err, ErrLoadingFailed) || errors.Is(err, ErrResourceLoadingFailed) || errors.Is(err, ErrInvalidEvaluationExpression) || errors.Is(err, ErrInvalidSelectorQuery):
|
} 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) {
|
||||||
reason = "invalid_input"
|
reason = "invalid_input"
|
||||||
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) || errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
|
} else if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) || errors.Is(err, gotenberg.ErrProcessAlreadyRestarting) {
|
||||||
reason = "chromium_unavailable"
|
reason = "chromium_unavailable"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -861,21 +816,15 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
|
|||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
|
||||||
mod.pdfOutputSizeCounter.Record(ctx, fileInfo.Size())
|
mod.pdfOutputSizeCounter.Record(ctx, fileInfo.Size())
|
||||||
}
|
}
|
||||||
|
|
||||||
span.SetStatus(codes.Ok, "")
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
|
func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||||
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Screenshot",
|
// Note: no error wrapping because it leaks on errors we want to display to
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
// the end user.
|
||||||
trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)),
|
ctx, span := gotenberg.Tracer().Start(ctx, "Chromium.Screenshot")
|
||||||
)
|
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
@@ -896,17 +845,19 @@ func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, o
|
|||||||
status = "error"
|
status = "error"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
|
||||||
reason := "unknown"
|
reason := "unknown"
|
||||||
switch {
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
case errors.Is(err, context.DeadlineExceeded):
|
|
||||||
reason = "timeout"
|
reason = "timeout"
|
||||||
case errors.Is(err, context.Canceled):
|
} else if errors.Is(err, context.Canceled) {
|
||||||
reason = "context_cancelled"
|
reason = "context_cancelled"
|
||||||
case errors.Is(err, ErrInvalidHttpStatusCode) || errors.Is(err, ErrInvalidResourceHttpStatusCode) || errors.Is(err, ErrLoadingFailed) || errors.Is(err, ErrResourceLoadingFailed) || errors.Is(err, ErrInvalidEvaluationExpression) || errors.Is(err, ErrInvalidSelectorQuery):
|
} 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) {
|
||||||
reason = "invalid_input"
|
reason = "invalid_input"
|
||||||
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded):
|
} else if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
|
||||||
reason = "chromium_maximum_queue_size_exceeded"
|
reason = "chromium_maximum_queue_size_exceeded"
|
||||||
case errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
|
} else if errors.Is(err, gotenberg.ErrProcessAlreadyRestarting) {
|
||||||
reason = "chromium_unavailable"
|
reason = "chromium_unavailable"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -940,26 +891,20 @@ func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, o
|
|||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
|
||||||
mod.imageOutputSizeCounter.Record(ctx, fileInfo.Size())
|
mod.imageOutputSizeCounter.Record(ctx, fileInfo.Size())
|
||||||
}
|
}
|
||||||
|
|
||||||
span.SetStatus(codes.Ok, "")
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interface guards.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ gotenberg.Module = (*Chromium)(nil)
|
_ gotenberg.Module = (*Chromium)(nil)
|
||||||
_ gotenberg.Provisioner = (*Chromium)(nil)
|
_ gotenberg.Provisioner = (*Chromium)(nil)
|
||||||
_ gotenberg.Validator = (*Chromium)(nil)
|
_ gotenberg.Validator = (*Chromium)(nil)
|
||||||
_ gotenberg.App = (*Chromium)(nil)
|
_ gotenberg.App = (*Chromium)(nil)
|
||||||
_ gotenberg.Debuggable = (*Chromium)(nil)
|
_ gotenberg.Debuggable = (*Chromium)(nil)
|
||||||
_ gotenberg.MetricsProvider = (*Chromium)(nil)
|
_ api.HealthChecker = (*Chromium)(nil)
|
||||||
_ api.HealthChecker = (*Chromium)(nil)
|
_ api.Router = (*Chromium)(nil)
|
||||||
_ api.Router = (*Chromium)(nil)
|
_ Api = (*Chromium)(nil)
|
||||||
_ Api = (*Chromium)(nil)
|
_ Provider = (*Chromium)(nil)
|
||||||
_ Provider = (*Chromium)(nil)
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,19 +10,20 @@ import (
|
|||||||
// debugLogger is wrapper around a [slog.Logger] which is used for debugging
|
// debugLogger is wrapper around a [slog.Logger] which is used for debugging
|
||||||
// Chromium.
|
// Chromium.
|
||||||
type debugLogger struct {
|
type debugLogger struct {
|
||||||
|
ctx context.Context
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write logs the bytes in a debug message.
|
// Write logs the bytes in a debug message.
|
||||||
func (debug *debugLogger) Write(p []byte) (n int, err error) {
|
func (debug *debugLogger) Write(p []byte) (n int, err error) {
|
||||||
debug.logger.DebugContext(context.Background(), string(p))
|
debug.logger.DebugContext(debug.ctx, string(p))
|
||||||
|
|
||||||
return len(p), nil
|
return len(p), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Printf logs a debug message.
|
// Printf logs a debug message.
|
||||||
func (debug *debugLogger) Printf(format string, v ...any) {
|
func (debug *debugLogger) Printf(format string, v ...any) {
|
||||||
debug.logger.DebugContext(context.Background(), fmt.Sprintf(format, v...))
|
debug.logger.DebugContext(debug.ctx, fmt.Sprintf(format, v...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interface guards.
|
// Interface guards.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
// Package chromium adds routes for converting HTML documents to PDF. Exposes
|
// Package chromium provides a module which adds routes for converting HTML
|
||||||
// an [Api] for other modules.
|
// documents to PDF. Other modules may also retrieve the [Api] provided by this
|
||||||
|
// module.
|
||||||
package chromium
|
package chromium
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package chromium
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -18,16 +17,14 @@ import (
|
|||||||
"github.com/chromedp/cdproto/runtime"
|
"github.com/chromedp/cdproto/runtime"
|
||||||
"github.com/chromedp/chromedp"
|
"github.com/chromedp/chromedp"
|
||||||
"github.com/dlclark/regexp2"
|
"github.com/dlclark/regexp2"
|
||||||
|
"go.uber.org/multierr"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
type eventRequestPausedOptions struct {
|
type eventRequestPausedOptions struct {
|
||||||
allowList, denyList []*regexp2.Regexp
|
allowList, denyList *regexp2.Regexp
|
||||||
denyPrivateIPs bool
|
|
||||||
denyPublicIPs bool
|
|
||||||
allowedFilePrefixes []string
|
|
||||||
extraHttpHeaders []ExtraHttpHeader
|
extraHttpHeaders []ExtraHttpHeader
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +40,8 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
|
|||||||
}
|
}
|
||||||
|
|
||||||
chromedp.ListenTarget(ctx, func(ev any) {
|
chromedp.ListenTarget(ctx, func(ev any) {
|
||||||
if e, ok := ev.(*fetch.EventRequestPaused); ok {
|
switch e := ev.(type) {
|
||||||
|
case *fetch.EventRequestPaused:
|
||||||
go func() {
|
go func() {
|
||||||
logger.DebugContext(ctx, fmt.Sprintf("event EventRequestPaused fired for '%s'", e.Request.URL))
|
logger.DebugContext(ctx, fmt.Sprintf("event EventRequestPaused fired for '%s'", e.Request.URL))
|
||||||
allow := true
|
allow := true
|
||||||
@@ -54,42 +52,16 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := gotenberg.FilterOutboundURL(ctx, e.Request.URL, options.allowList, options.denyList, deadline,
|
err := gotenberg.FilterDeadline(options.allowList, options.denyList, e.Request.URL, deadline)
|
||||||
gotenberg.WithDenyPrivateIPs(options.denyPrivateIPs),
|
|
||||||
gotenberg.WithDenyPublicIPs(options.denyPublicIPs),
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.WarnContext(ctx, err.Error())
|
logger.WarnContext(ctx, err.Error())
|
||||||
allow = false
|
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)
|
cctx := chromedp.FromContext(ctx)
|
||||||
executorCtx := cdp.WithExecutor(ctx, cctx.Target)
|
executorCtx := cdp.WithExecutor(ctx, cctx.Target)
|
||||||
|
|
||||||
if !allow {
|
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)
|
req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
|
||||||
err = req.Do(executorCtx)
|
err = req.Do(executorCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -115,13 +87,12 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
|
|||||||
}
|
}
|
||||||
|
|
||||||
ok, err := header.Scope.MatchString(e.Request.URL)
|
ok, err := header.Scope.MatchString(e.Request.URL)
|
||||||
switch {
|
if err != nil {
|
||||||
case err != nil:
|
|
||||||
logger.ErrorContext(ctx, fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err))
|
logger.ErrorContext(ctx, fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err))
|
||||||
case ok:
|
} else if ok {
|
||||||
logger.DebugContext(ctx, fmt.Sprintf("extra HTTP header '%s' (scoped) will be set for request URL '%s'", header.Name, e.Request.URL))
|
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)
|
extraHttpHeadersToSet = append(extraHttpHeadersToSet, header)
|
||||||
default:
|
} else {
|
||||||
logger.DebugContext(ctx, fmt.Sprintf("scoped extra HTTP header '%s' (scoped) will not be set for request URL '%s'", header.Name, e.Request.URL))
|
logger.DebugContext(ctx, fmt.Sprintf("scoped extra HTTP header '%s' (scoped) will not be set for request URL '%s'", header.Name, e.Request.URL))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,7 +148,6 @@ type eventResponseReceivedOptions struct {
|
|||||||
ignoreResourceHttpStatusDomains []string
|
ignoreResourceHttpStatusDomains []string
|
||||||
invalidResourceHttpStatusCode *error
|
invalidResourceHttpStatusCode *error
|
||||||
invalidResourceHttpStatusCodeMu *sync.RWMutex
|
invalidResourceHttpStatusCodeMu *sync.RWMutex
|
||||||
cancelOnMainPageError context.CancelFunc
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// listenForEventResponseReceived listens for an invalid HTTP status code
|
// listenForEventResponseReceived listens for an invalid HTTP status code
|
||||||
@@ -207,7 +177,8 @@ func listenForEventResponseReceived(
|
|||||||
}
|
}
|
||||||
|
|
||||||
chromedp.ListenTarget(ctx, func(ev any) {
|
chromedp.ListenTarget(ctx, func(ev any) {
|
||||||
if ev, ok := ev.(*network.EventResponseReceived); ok {
|
switch ev := ev.(type) {
|
||||||
|
case *network.EventResponseReceived:
|
||||||
if ev.Response.URL == options.mainPageUrl {
|
if ev.Response.URL == options.mainPageUrl {
|
||||||
logger.DebugContext(ctx, fmt.Sprintf("event EventResponseReceived fired for main page: %+v", ev.Response))
|
logger.DebugContext(ctx, fmt.Sprintf("event EventResponseReceived fired for main page: %+v", ev.Response))
|
||||||
|
|
||||||
@@ -216,14 +187,6 @@ func listenForEventResponseReceived(
|
|||||||
defer options.invalidHttpStatusCodeMu.Unlock()
|
defer options.invalidHttpStatusCodeMu.Unlock()
|
||||||
|
|
||||||
*options.invalidHttpStatusCode = fmt.Errorf("%d: %s", ev.Response.Status, ev.Response.StatusText)
|
*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
|
return
|
||||||
@@ -240,7 +203,7 @@ func listenForEventResponseReceived(
|
|||||||
options.invalidResourceHttpStatusCodeMu.Lock()
|
options.invalidResourceHttpStatusCodeMu.Lock()
|
||||||
defer options.invalidResourceHttpStatusCodeMu.Unlock()
|
defer options.invalidResourceHttpStatusCodeMu.Unlock()
|
||||||
|
|
||||||
*options.invalidResourceHttpStatusCode = errors.Join(
|
*options.invalidResourceHttpStatusCode = multierr.Append(
|
||||||
*options.invalidResourceHttpStatusCode,
|
*options.invalidResourceHttpStatusCode,
|
||||||
fmt.Errorf("%s - %d: %s", ev.Response.URL, ev.Response.Status, http.StatusText(int(ev.Response.Status))),
|
fmt.Errorf("%s - %d: %s", ev.Response.URL, ev.Response.Status, http.StatusText(int(ev.Response.Status))),
|
||||||
)
|
)
|
||||||
@@ -249,23 +212,6 @@ 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 {
|
func shouldCheckResourceHttpStatusCode(rawURL string, ignoreDomains []string) bool {
|
||||||
host := hostnameFromURL(rawURL)
|
host := hostnameFromURL(rawURL)
|
||||||
|
|
||||||
@@ -344,7 +290,6 @@ type eventLoadingFailedOptions struct {
|
|||||||
loadingFailedMu *sync.RWMutex
|
loadingFailedMu *sync.RWMutex
|
||||||
resourceLoadingFailed *error
|
resourceLoadingFailed *error
|
||||||
resourceLoadingFailedMu *sync.RWMutex
|
resourceLoadingFailedMu *sync.RWMutex
|
||||||
cancelOnMainPageError context.CancelFunc
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// listenForEventLoadingFailed listens for an event indicating that the main
|
// listenForEventLoadingFailed listens for an event indicating that the main
|
||||||
@@ -355,12 +300,13 @@ type eventLoadingFailedOptions struct {
|
|||||||
// https://github.com/gotenberg/gotenberg/issues/1021.
|
// https://github.com/gotenberg/gotenberg/issues/1021.
|
||||||
func listenForEventLoadingFailed(ctx context.Context, logger *slog.Logger, options eventLoadingFailedOptions) {
|
func listenForEventLoadingFailed(ctx context.Context, logger *slog.Logger, options eventLoadingFailedOptions) {
|
||||||
chromedp.ListenTarget(ctx, func(ev any) {
|
chromedp.ListenTarget(ctx, func(ev any) {
|
||||||
if ev, ok := ev.(*network.EventLoadingFailed); ok {
|
switch ev := ev.(type) {
|
||||||
|
case *network.EventLoadingFailed:
|
||||||
logger.DebugContext(ctx, fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText))
|
logger.DebugContext(ctx, fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText))
|
||||||
|
|
||||||
// We are looking for common errors.
|
// We are looking for common errors.
|
||||||
// TODO: sufficient?
|
// TODO: sufficient?
|
||||||
knownErrors := []string{
|
errors := []string{
|
||||||
"net::ERR_CONNECTION_CLOSED",
|
"net::ERR_CONNECTION_CLOSED",
|
||||||
"net::ERR_CONNECTION_RESET",
|
"net::ERR_CONNECTION_RESET",
|
||||||
"net::ERR_CONNECTION_REFUSED",
|
"net::ERR_CONNECTION_REFUSED",
|
||||||
@@ -374,8 +320,8 @@ func listenForEventLoadingFailed(ctx context.Context, logger *slog.Logger, optio
|
|||||||
"net::ERR_FILE_NOT_FOUND",
|
"net::ERR_FILE_NOT_FOUND",
|
||||||
"net::ERR_HTTP2_PROTOCOL_ERROR",
|
"net::ERR_HTTP2_PROTOCOL_ERROR",
|
||||||
}
|
}
|
||||||
if !slices.Contains(knownErrors, ev.ErrorText) {
|
if !slices.Contains(errors, ev.ErrorText) {
|
||||||
logger.DebugContext(ctx, fmt.Sprintf("skip EventLoadingFailed: '%s' is not part of %+v", ev.ErrorText, knownErrors))
|
logger.DebugContext(ctx, fmt.Sprintf("skip EventLoadingFailed: '%s' is not part of %+v", ev.ErrorText, errors))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,13 +335,6 @@ func listenForEventLoadingFailed(ctx context.Context, logger *slog.Logger, optio
|
|||||||
|
|
||||||
*options.loadingFailed = fmt.Errorf("%s", ev.ErrorText)
|
*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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +343,7 @@ func listenForEventLoadingFailed(ctx context.Context, logger *slog.Logger, optio
|
|||||||
options.resourceLoadingFailedMu.Lock()
|
options.resourceLoadingFailedMu.Lock()
|
||||||
defer options.resourceLoadingFailedMu.Unlock()
|
defer options.resourceLoadingFailedMu.Unlock()
|
||||||
|
|
||||||
*options.resourceLoadingFailed = errors.Join(
|
*options.resourceLoadingFailed = multierr.Append(
|
||||||
*options.resourceLoadingFailed,
|
*options.resourceLoadingFailed,
|
||||||
fmt.Errorf("resource %s: %s", ev.Type, ev.ErrorText),
|
fmt.Errorf("resource %s: %s", ev.Type, ev.ErrorText),
|
||||||
)
|
)
|
||||||
@@ -417,13 +356,14 @@ func listenForEventLoadingFailed(ctx context.Context, logger *slog.Logger, optio
|
|||||||
// See https://github.com/gotenberg/gotenberg/issues/262.
|
// See https://github.com/gotenberg/gotenberg/issues/262.
|
||||||
func listenForEventExceptionThrown(ctx context.Context, logger *slog.Logger, consoleExceptions *error, consoleExceptionsMu *sync.RWMutex) {
|
func listenForEventExceptionThrown(ctx context.Context, logger *slog.Logger, consoleExceptions *error, consoleExceptionsMu *sync.RWMutex) {
|
||||||
chromedp.ListenTarget(ctx, func(ev any) {
|
chromedp.ListenTarget(ctx, func(ev any) {
|
||||||
if ev, ok := ev.(*runtime.EventExceptionThrown); ok {
|
switch ev := ev.(type) {
|
||||||
|
case *runtime.EventExceptionThrown:
|
||||||
logger.DebugContext(ctx, fmt.Sprintf("event EventExceptionThrown fired: %+v", ev.ExceptionDetails))
|
logger.DebugContext(ctx, fmt.Sprintf("event EventExceptionThrown fired: %+v", ev.ExceptionDetails))
|
||||||
|
|
||||||
consoleExceptionsMu.Lock()
|
consoleExceptionsMu.Lock()
|
||||||
defer consoleExceptionsMu.Unlock()
|
defer consoleExceptionsMu.Unlock()
|
||||||
|
|
||||||
*consoleExceptions = errors.Join(*consoleExceptions, fmt.Errorf("\n%+v", ev.ExceptionDetails))
|
*consoleExceptions = multierr.Append(*consoleExceptions, fmt.Errorf("\n%+v", ev.ExceptionDetails))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -435,7 +375,8 @@ func waitForEventDomContentEventFired(ctx context.Context, logger *slog.Logger)
|
|||||||
ch := make(chan struct{})
|
ch := make(chan struct{})
|
||||||
cctx, cancel := context.WithCancel(ctx)
|
cctx, cancel := context.WithCancel(ctx)
|
||||||
chromedp.ListenTarget(cctx, func(ev any) {
|
chromedp.ListenTarget(cctx, func(ev any) {
|
||||||
if _, ok := ev.(*page.EventDomContentEventFired); ok {
|
switch ev.(type) {
|
||||||
|
case *page.EventDomContentEventFired:
|
||||||
cancel()
|
cancel()
|
||||||
close(ch)
|
close(ch)
|
||||||
}
|
}
|
||||||
@@ -458,7 +399,8 @@ func waitForEventLoadEventFired(ctx context.Context, logger *slog.Logger) func()
|
|||||||
ch := make(chan struct{})
|
ch := make(chan struct{})
|
||||||
cctx, cancel := context.WithCancel(ctx)
|
cctx, cancel := context.WithCancel(ctx)
|
||||||
chromedp.ListenTarget(cctx, func(ev any) {
|
chromedp.ListenTarget(cctx, func(ev any) {
|
||||||
if _, ok := ev.(*page.EventLoadEventFired); ok {
|
switch ev.(type) {
|
||||||
|
case *page.EventLoadEventFired:
|
||||||
cancel()
|
cancel()
|
||||||
close(ch)
|
close(ch)
|
||||||
}
|
}
|
||||||
@@ -481,9 +423,12 @@ func waitForEventNetworkIdle(ctx context.Context, logger *slog.Logger) func() er
|
|||||||
ch := make(chan struct{})
|
ch := make(chan struct{})
|
||||||
cctx, cancel := context.WithCancel(ctx)
|
cctx, cancel := context.WithCancel(ctx)
|
||||||
chromedp.ListenTarget(cctx, func(ev any) {
|
chromedp.ListenTarget(cctx, func(ev any) {
|
||||||
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle" {
|
switch e := ev.(type) {
|
||||||
cancel()
|
case *page.EventLifecycleEvent:
|
||||||
close(ch)
|
if e.Name == "networkIdle" {
|
||||||
|
cancel()
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -504,9 +449,12 @@ func waitForEventNetworkAlmostIdle(ctx context.Context, logger *slog.Logger) fun
|
|||||||
ch := make(chan struct{})
|
ch := make(chan struct{})
|
||||||
cctx, cancel := context.WithCancel(ctx)
|
cctx, cancel := context.WithCancel(ctx)
|
||||||
chromedp.ListenTarget(cctx, func(ev any) {
|
chromedp.ListenTarget(cctx, func(ev any) {
|
||||||
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle2" {
|
switch e := ev.(type) {
|
||||||
cancel()
|
case *page.EventLifecycleEvent:
|
||||||
close(ch)
|
if e.Name == "networkIdle2" {
|
||||||
|
cancel()
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -527,7 +475,8 @@ func waitForEventLoadingFinished(ctx context.Context, logger *slog.Logger) func(
|
|||||||
ch := make(chan struct{})
|
ch := make(chan struct{})
|
||||||
cctx, cancel := context.WithCancel(ctx)
|
cctx, cancel := context.WithCancel(ctx)
|
||||||
chromedp.ListenTarget(cctx, func(ev any) {
|
chromedp.ListenTarget(cctx, func(ev any) {
|
||||||
if _, ok := ev.(*network.EventLoadingFinished); ok {
|
switch ev.(type) {
|
||||||
|
case *network.EventLoadingFinished:
|
||||||
cancel()
|
cancel()
|
||||||
close(ch)
|
close(ch)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,49 +61,3 @@ func TestShouldCheckResourceHttpStatusCode_NonHTTPURL(t *testing.T) {
|
|||||||
t.Fatalf("expected data: URL to be checked (no host filtering possible)")
|
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,328 +0,0 @@
|
|||||||
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 {
|
|
||||||
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 {
|
|
||||||
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 {
|
|
||||||
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 {
|
|
||||||
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 {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,485 +0,0 @@
|
|||||||
package chromium
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/netip"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/dlclark/regexp2"
|
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,7 +7,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -18,6 +17,7 @@ import (
|
|||||||
"github.com/gomarkdown/markdown"
|
"github.com/gomarkdown/markdown"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/microcosm-cc/bluemonday"
|
"github.com/microcosm-cc/bluemonday"
|
||||||
|
"go.uber.org/multierr"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
"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 {
|
for i, cookie := range cookies {
|
||||||
if strings.TrimSpace(cookie.Name) == "" || strings.TrimSpace(cookie.Value) == "" || strings.TrimSpace(cookie.Domain) == "" {
|
if strings.TrimSpace(cookie.Name) == "" || strings.TrimSpace(cookie.Value) == "" || strings.TrimSpace(cookie.Domain) == "" {
|
||||||
err = errors.Join(err, fmt.Errorf("cookie %d must have its name, value and domain set", i))
|
err = multierr.Append(err, fmt.Errorf("cookie %d must have its name, value and domain set", i))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,12 +183,14 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
|||||||
if len(parts) == 2 && strings.ToLower(parts[0]) == "scope" && parts[1] != "" {
|
if len(parts) == 2 && strings.ToLower(parts[0]) == "scope" && parts[1] != "" {
|
||||||
scope = parts[1]
|
scope = parts[1]
|
||||||
} else {
|
} else {
|
||||||
err = errors.Join(err, fmt.Errorf("invalid scope '%s' for header '%s'", scope, k))
|
err = multierr.Append(err, fmt.Errorf("invalid scope '%s' for header '%s'", scope, k))
|
||||||
invalidScopeToken = true
|
invalidScopeToken = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
} else if token != "" {
|
} else {
|
||||||
valueTokens = append(valueTokens, token)
|
if token != "" {
|
||||||
|
valueTokens = append(valueTokens, token)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,10 +202,9 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
|||||||
if len(scope) > 0 {
|
if len(scope) > 0 {
|
||||||
p, errCompile := regexp2.Compile(scope, regexp2.None)
|
p, errCompile := regexp2.Compile(scope, regexp2.None)
|
||||||
if errCompile != nil {
|
if errCompile != nil {
|
||||||
err = errors.Join(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile))
|
err = multierr.Append(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
p.MatchTimeout = 5 * time.Second
|
|
||||||
scopeRegexp = p
|
scopeRegexp = p
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,34 +404,6 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
|||||||
return form, screenshotOptions
|
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.
|
// convertUrlRoute returns an [api.Route] which can convert a URL to PDF.
|
||||||
func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||||
return api.Route{
|
return api.Route{
|
||||||
@@ -444,13 +417,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||||
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
||||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
attachmentsPaths := pdfengines.FormDataPdfAttachments(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
|
var url string
|
||||||
err := form.
|
err := form.
|
||||||
@@ -460,21 +427,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("validate form data: %w", err)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = rejectFileScheme(url)
|
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, attachmentsPaths)
|
||||||
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("convert URL to PDF: %w", err)
|
return fmt.Errorf("convert URL to PDF: %w", err)
|
||||||
}
|
}
|
||||||
@@ -503,11 +456,6 @@ func screenshotUrlRoute(chromium Api) api.Route {
|
|||||||
return fmt.Errorf("validate form data: %w", err)
|
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)
|
err = screenshotUrl(ctx, chromium, url, options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("URL screenshot: %w", err)
|
return fmt.Errorf("URL screenshot: %w", err)
|
||||||
@@ -532,13 +480,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||||
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
||||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
attachmentsPaths := pdfengines.FormDataPdfAttachments(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
|
var inputPath string
|
||||||
err := form.
|
err := form.
|
||||||
@@ -548,18 +490,8 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("validate form data: %w", err)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
|
|
||||||
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)
|
url := fmt.Sprintf("file://%s", inputPath)
|
||||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, attachmentsPaths)
|
||||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert HTML to PDF: %w", err)
|
return fmt.Errorf("convert HTML to PDF: %w", err)
|
||||||
}
|
}
|
||||||
@@ -589,7 +521,6 @@ func screenshotHtmlRoute(chromium Api) api.Route {
|
|||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("file://%s", inputPath)
|
url := fmt.Sprintf("file://%s", inputPath)
|
||||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
|
||||||
err = screenshotUrl(ctx, chromium, url, options)
|
err = screenshotUrl(ctx, chromium, url, options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("HTML screenshot: %w", err)
|
return fmt.Errorf("HTML screenshot: %w", err)
|
||||||
@@ -614,13 +545,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||||
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
||||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
attachmentsPaths := pdfengines.FormDataPdfAttachments(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 (
|
var (
|
||||||
inputPath string
|
inputPath string
|
||||||
@@ -635,22 +560,12 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("validate form data: %w", err)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
|
|
||||||
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)
|
url, err := markdownToHtml(ctx, inputPath, markdownPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
|
return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, attachmentsPaths)
|
||||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert markdown to PDF: %w", err)
|
return fmt.Errorf("convert markdown to PDF: %w", err)
|
||||||
}
|
}
|
||||||
@@ -689,7 +604,6 @@ func screenshotMarkdownRoute(chromium Api) api.Route {
|
|||||||
return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
|
return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
|
||||||
err = screenshotUrl(ctx, chromium, url, options)
|
err = screenshotUrl(ctx, chromium, url, options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("markdown screenshot: %w", err)
|
return fmt.Errorf("markdown screenshot: %w", err)
|
||||||
@@ -715,7 +629,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
|
|||||||
var path string
|
var path string
|
||||||
|
|
||||||
for _, markdownPath := range markdownPaths {
|
for _, markdownPath := range markdownPaths {
|
||||||
markdownFilename := ctx.OriginalFilename(markdownPath)
|
markdownFilename := filepath.Base(markdownPath)
|
||||||
|
|
||||||
if filename == markdownFilename {
|
if filename == markdownFilename {
|
||||||
path = markdownPath
|
path = markdownPath
|
||||||
@@ -724,7 +638,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if path == "" {
|
if path == "" {
|
||||||
markdownFilesNotFoundErr = errors.Join(
|
markdownFilesNotFoundErr = multierr.Append(
|
||||||
markdownFilesNotFoundErr,
|
markdownFilesNotFoundErr,
|
||||||
fmt.Errorf("'%s'", filename),
|
fmt.Errorf("'%s'", filename),
|
||||||
)
|
)
|
||||||
@@ -775,7 +689,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
|
|||||||
return fmt.Sprintf("file://%s", inputPath), nil
|
return fmt.Sprintf("file://%s", inputPath), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, embedPaths []string, embedsMetadata map[string]map[string]string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error {
|
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, attachmentsPaths []string) error {
|
||||||
outputPath := ctx.GeneratePath(".pdf")
|
outputPath := ctx.GeneratePath(".pdf")
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/1130.
|
// See https://github.com/gotenberg/gotenberg/issues/1130.
|
||||||
filename := ctx.OutputFilename(outputPath)
|
filename := ctx.OutputFilename(outputPath)
|
||||||
@@ -837,53 +751,26 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
|||||||
return fmt.Errorf("convert to PDF: %w", err)
|
return fmt.Errorf("convert to PDF: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
outputPaths, err := pdfengines.SplitPdfStub(ctx, engine, mode, []string{outputPath})
|
outputPaths, err := pdfengines.SplitPdfStub(ctx, engine, mode, []string{outputPath})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("split PDF: %w", err)
|
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)
|
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert PDF(s): %w", err)
|
return fmt.Errorf("convert PDF(s): %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metadata, embeds are written after Convert, as LibreOffice
|
err = pdfengines.AddAttachmentsStub(ctx, engine, attachmentsPaths, convertOutputPaths)
|
||||||
// strips them during PDF/A conversion.
|
if err != nil {
|
||||||
|
return fmt.Errorf("add attachments into PDFs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
err = pdfengines.WriteMetadataStub(ctx, engine, metadata, convertOutputPaths)
|
err = pdfengines.WriteMetadataStub(ctx, engine, metadata, convertOutputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("write metadata: %w", err)
|
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)
|
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||||
|
|||||||
@@ -31,9 +31,7 @@ func printToPdfActionFunc(logger *slog.Logger, outputPath string, options PdfOpt
|
|||||||
|
|
||||||
// There are 96 CSS pixels per inch.
|
// There are 96 CSS pixels per inch.
|
||||||
// See https://issues.chromium.org/issues/40267771#comment14.
|
// See https://issues.chromium.org/issues/40267771#comment14.
|
||||||
// We add top and bottom margins so that the content area
|
paperHeight = cssContentSize.Height / 96
|
||||||
// is large enough to fit the entire content.
|
|
||||||
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
|
|
||||||
pageRanges = "1" // little dirty hack to avoid leftovers.
|
pageRanges = "1" // little dirty hack to avoid leftovers.
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,28 +303,28 @@ func userAgentOverride(logger *slog.Logger, userAgent string) chromedp.ActionFun
|
|||||||
// network.SetExtraHTTPHeaders set the headers for ALL requests from the page.
|
// network.SetExtraHTTPHeaders set the headers for ALL requests from the page.
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/1011.
|
// See https://github.com/gotenberg/gotenberg/issues/1011.
|
||||||
//
|
//
|
||||||
// func extraHttpHeadersActionFunc(logger *slog.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
|
//func extraHttpHeadersActionFunc(logger *slog.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
|
||||||
// return func(ctx context.Context) error {
|
// return func(ctx context.Context) error {
|
||||||
// if len(extraHttpHeaders) == 0 {
|
// if len(extraHttpHeaders) == 0 {
|
||||||
// logger.DebugContext(ctx,"no extra HTTP headers")
|
// logger.DebugContext(ctx, "no extra HTTP headers")
|
||||||
// return nil
|
// 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))
|
// headers := make(network.Headers, len(extraHttpHeaders))
|
||||||
// for key, value := range extraHttpHeaders {
|
// for key, value := range extraHttpHeaders {
|
||||||
// headers[key] = value
|
// headers[key] = value
|
||||||
// }
|
// }
|
||||||
//
|
//
|
||||||
// err := network.SetExtraHTTPHeaders(headers).Do(ctx)
|
// err := network.SetExtraHTTPHeaders(headers).Do(ctx)
|
||||||
// if err == nil {
|
// if err == nil {
|
||||||
// return 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 {
|
func navigateActionFunc(logger *slog.Logger, url string, skipNetworkIdleEvent, skipNetworkAlmostIdleEvent bool) chromedp.ActionFunc {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
// Package exiftool implements gotenberg.PdfEngine using the ExifTool command-line tool. Reads and writes PDF metadata.
|
// Package exiftool provides an implementation of the gotenberg.PdfEngine
|
||||||
|
// interface using the ExifTool command-line tool. This package allows for:
|
||||||
//
|
//
|
||||||
// Requires the EXIFTOOL_BIN_PATH environment variable.
|
// 1. The reading of metadata.
|
||||||
|
// 2. The writing of metadata.
|
||||||
//
|
//
|
||||||
// See https://exiftool.org.
|
// The path to the exiftool binary must be specified using the
|
||||||
|
// EXIFTOOL_BIN_PATH environment variable.
|
||||||
|
//
|
||||||
|
// See: https://exiftool.org.
|
||||||
package exiftool
|
package exiftool
|
||||||
|
|||||||
@@ -2,18 +2,17 @@ package exiftool
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"regexp"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/barasher/go-exiftool"
|
||||||
"go.opentelemetry.io/otel/codes"
|
"go.opentelemetry.io/otel/codes"
|
||||||
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
|
||||||
"go.opentelemetry.io/otel/trace"
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
@@ -23,138 +22,6 @@ func init() {
|
|||||||
gotenberg.MustRegisterModule(new(ExifTool))
|
gotenberg.MustRegisterModule(new(ExifTool))
|
||||||
}
|
}
|
||||||
|
|
||||||
// safeKeyPattern matches legitimate ExifTool tag names: alphanumeric,
|
|
||||||
// hyphens, underscores, colons, and periods. The first character may not
|
|
||||||
// be a hyphen, otherwise exiftool would treat the argv entry as a flag
|
|
||||||
// rather than a tag assignment. Control characters are implicitly
|
|
||||||
// rejected because the class is ASCII-only.
|
|
||||||
var safeKeyPattern = regexp.MustCompile(`^[a-zA-Z0-9_.:][a-zA-Z0-9\-_.:]*$`)
|
|
||||||
|
|
||||||
// validateMetadataValue rejects metadata values containing NUL, newline,
|
|
||||||
// or carriage return. NUL terminates C strings and is rejected by
|
|
||||||
// [exec.Cmd] anyway; newlines and carriage returns are rejected as
|
|
||||||
// defense in depth against exiftool parsing quirks, even though argv
|
|
||||||
// invocation is not susceptible to stdin-protocol injection the way
|
|
||||||
// the previous go-exiftool backend was. The returned error wraps
|
|
||||||
// [gotenberg.ErrPdfEngineMetadataValueNotSupported] so the API layer
|
|
||||||
// surfaces it as HTTP 400.
|
|
||||||
func validateMetadataValue(key, value string) error {
|
|
||||||
if strings.ContainsAny(value, "\n\r\x00") {
|
|
||||||
return fmt.Errorf("write PDF metadata with ExifTool: invalid metadata value for key %q (contains control character): %w", key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// systemTags lists ExifTool tags that reflect internal filesystem state
|
|
||||||
// or tool identity rather than actual PDF metadata. Stripped from read
|
|
||||||
// output before returning to the caller.
|
|
||||||
var systemTags = []string{
|
|
||||||
"SourceFile", // Full path exiftool -j always emits first
|
|
||||||
"FileName", // Reflects UUID-based disk name, not original filename
|
|
||||||
"Directory", // Leaks internal temp path
|
|
||||||
"FileSize", // System attribute
|
|
||||||
"FileModifyDate", // System attribute
|
|
||||||
"FileAccessDate", // System attribute
|
|
||||||
"FileInodeChangeDate", // System attribute
|
|
||||||
"FilePermissions", // System attribute
|
|
||||||
"ExifToolVersion", // Tool metadata
|
|
||||||
"Error", // Extraction error messages
|
|
||||||
"Warning", // Extraction warning messages
|
|
||||||
}
|
|
||||||
|
|
||||||
// dangerousTags lists ExifTool pseudo-tags that trigger filesystem side
|
|
||||||
// effects (file rename, move, link creation, permission change). Writes
|
|
||||||
// containing any of these keys are silently dropped before the argv is
|
|
||||||
// handed to exiftool. The comparison strips group prefixes (e.g.
|
|
||||||
// "System:FileName" collapses to "FileName") because exiftool treats
|
|
||||||
// the prefixed and bare forms identically.
|
|
||||||
//
|
|
||||||
// See https://exiftool.org/TagNames/Extra.html.
|
|
||||||
var dangerousTags = []string{
|
|
||||||
"FileName", // Writing this triggers a file rename in ExifTool
|
|
||||||
"Directory", // Writing this triggers a file move in ExifTool
|
|
||||||
"HardLink", // Writing this creates a hard link in ExifTool
|
|
||||||
"SymLink", // Writing this creates a symbolic link in ExifTool
|
|
||||||
"FilePermissions", // Writing this changes the file's permissions
|
|
||||||
}
|
|
||||||
|
|
||||||
// isDangerousTag reports whether key matches one of the [dangerousTags]
|
|
||||||
// after case-insensitive comparison with any group prefix stripped.
|
|
||||||
func isDangerousTag(key string) bool {
|
|
||||||
bare := key
|
|
||||||
if i := strings.LastIndex(key, ":"); i >= 0 {
|
|
||||||
bare = key[i+1:]
|
|
||||||
}
|
|
||||||
for _, tag := range dangerousTags {
|
|
||||||
if strings.EqualFold(bare, tag) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildExifToolWriteArgs builds the variadic argv tail for
|
|
||||||
//
|
|
||||||
// exiftool -overwrite_original <args> <path>
|
|
||||||
//
|
|
||||||
// from a user-supplied metadata map. Dangerous pseudo-tags are silently
|
|
||||||
// dropped. Invalid keys (empty, leading dash, control characters) and
|
|
||||||
// values containing NUL or newlines return an error wrapping
|
|
||||||
// [gotenberg.ErrPdfEngineMetadataValueNotSupported] so the API layer
|
|
||||||
// replies with HTTP 400. Supported value kinds: string, []string,
|
|
||||||
// []any of strings, bool, int, int64, float32, float64.
|
|
||||||
func buildExifToolWriteArgs(metadata map[string]any) ([]string, error) {
|
|
||||||
var args []string
|
|
||||||
for key, value := range metadata {
|
|
||||||
if isDangerousTag(key) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !safeKeyPattern.MatchString(key) {
|
|
||||||
return nil, fmt.Errorf("write PDF metadata with ExifTool: invalid metadata key %q: %w", key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch val := value.(type) {
|
|
||||||
case string:
|
|
||||||
if err := validateMetadataValue(key, val); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
args = append(args, fmt.Sprintf("-%s=%s", key, val))
|
|
||||||
case []string:
|
|
||||||
for _, s := range val {
|
|
||||||
if err := validateMetadataValue(key, s); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
args = append(args, fmt.Sprintf("-%s=%s", key, s))
|
|
||||||
}
|
|
||||||
case []any:
|
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/1048.
|
|
||||||
for _, entry := range val {
|
|
||||||
s, ok := entry.(string)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("write PDF metadata with ExifTool: unsupported element type %T in []any for key %q: %w", entry, key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
|
||||||
}
|
|
||||||
if err := validateMetadataValue(key, s); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
args = append(args, fmt.Sprintf("-%s=%s", key, s))
|
|
||||||
}
|
|
||||||
case bool:
|
|
||||||
args = append(args, fmt.Sprintf("-%s=%t", key, val))
|
|
||||||
case int:
|
|
||||||
args = append(args, fmt.Sprintf("-%s=%d", key, val))
|
|
||||||
case int64:
|
|
||||||
args = append(args, fmt.Sprintf("-%s=%d", key, val))
|
|
||||||
case float32:
|
|
||||||
args = append(args, fmt.Sprintf("-%s=%g", key, val))
|
|
||||||
case float64:
|
|
||||||
args = append(args, fmt.Sprintf("-%s=%g", key, val))
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("write PDF metadata with ExifTool: unsupported type %T for key %q: %w", value, key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return args, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExifTool abstracts the CLI tool ExifTool and implements the
|
// ExifTool abstracts the CLI tool ExifTool and implements the
|
||||||
// [gotenberg.PdfEngine] interface.
|
// [gotenberg.PdfEngine] interface.
|
||||||
type ExifTool struct {
|
type ExifTool struct {
|
||||||
@@ -210,319 +77,225 @@ func (engine *ExifTool) Debug() map[string]any {
|
|||||||
|
|
||||||
// Merge is not available in this implementation.
|
// Merge is not available in this implementation.
|
||||||
func (engine *ExifTool) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
func (engine *ExifTool) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Merge",
|
_, span := gotenberg.Tracer().Start(ctx, "ExifTool.Merge", trace.WithSpanKind(trace.SpanKindInternal))
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
|
||||||
)
|
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
err := fmt.Errorf("merge PDFs with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
err := fmt.Errorf("merge PDFs with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
span.RecordError(err)
|
if err != nil {
|
||||||
span.SetStatus(codes.Error, err.Error())
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split is not available in this implementation.
|
// Split is not available in this implementation.
|
||||||
func (engine *ExifTool) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
func (engine *ExifTool) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Split",
|
_, span := gotenberg.Tracer().Start(ctx, "ExifTool.Split", trace.WithSpanKind(trace.SpanKindInternal))
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
|
||||||
)
|
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
err := fmt.Errorf("split PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
err := fmt.Errorf("split PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
span.RecordError(err)
|
if err != nil {
|
||||||
span.SetStatus(codes.Error, err.Error())
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flatten is not available in this implementation.
|
// Flatten is not available in this implementation.
|
||||||
func (engine *ExifTool) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
|
func (engine *ExifTool) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Flatten",
|
_, span := gotenberg.Tracer().Start(ctx, "ExifTool.Flatten", trace.WithSpanKind(trace.SpanKindInternal))
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
|
||||||
)
|
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
err := fmt.Errorf("flatten PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
err := fmt.Errorf("flatten PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
span.RecordError(err)
|
if err != nil {
|
||||||
span.SetStatus(codes.Error, err.Error())
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert is not available in this implementation.
|
// Convert is not available in this implementation.
|
||||||
func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Convert",
|
_, span := gotenberg.Tracer().Start(ctx, "ExifTool.Convert", trace.WithSpanKind(trace.SpanKindInternal))
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
|
||||||
)
|
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
err := fmt.Errorf("convert PDF to '%+v' with ExifTool: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
|
err := fmt.Errorf("convert PDF to '%+v' with ExifTool: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
span.RecordError(err)
|
if err != nil {
|
||||||
span.SetStatus(codes.Error, err.Error())
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadMetadata extracts the metadata of a given PDF file by invoking
|
// ReadMetadata extracts the metadata of a given PDF file.
|
||||||
// the exiftool binary with "-j" (JSON output) and parsing the result.
|
|
||||||
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.ReadMetadata",
|
_, span := gotenberg.Tracer().Start(ctx, "ExifTool.ReadMetadata", trace.WithSpanKind(trace.SpanKindInternal))
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
|
||||||
)
|
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, engine.binPath, "-j", inputPath) //nolint:gosec
|
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
||||||
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = fmt.Errorf("read metadata with ExifTool: %w", err)
|
err = fmt.Errorf("new ExifTool: %w", err)
|
||||||
span.RecordError(err)
|
span.RecordError(err)
|
||||||
span.SetStatus(codes.Error, err.Error())
|
span.SetStatus(codes.Error, err.Error())
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var files []map[string]any
|
defer func(exifTool *exiftool.Exiftool) {
|
||||||
err = json.Unmarshal(output, &files)
|
err := exifTool.Close()
|
||||||
if err != nil {
|
|
||||||
err = fmt.Errorf("parse ExifTool JSON output: %w", err)
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(files) == 0 {
|
|
||||||
err = errors.New("ExifTool returned no file entries")
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata := files[0]
|
|
||||||
|
|
||||||
// ExifTool records extraction errors as an "Error" key on the file
|
|
||||||
// entry rather than via a non-zero exit code. Surface that back as a
|
|
||||||
// Go error before stripping so callers see the real cause.
|
|
||||||
if msg, ok := metadata["Error"].(string); ok && msg != "" {
|
|
||||||
err = fmt.Errorf("read metadata with ExifTool: %s", msg)
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tag := range systemTags {
|
|
||||||
delete(metadata, tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
span.SetStatus(codes.Ok, "")
|
|
||||||
return metadata, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// WriteMetadata writes the metadata into a given PDF file by invoking
|
|
||||||
// the exiftool binary with "-overwrite_original -TAG=VALUE ... path".
|
|
||||||
// ExifTool preserves tags that are not mentioned in the argv, so the
|
|
||||||
// write is a merge rather than a rewrite.
|
|
||||||
func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
|
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.WriteMetadata",
|
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
|
||||||
)
|
|
||||||
defer span.End()
|
|
||||||
|
|
||||||
extraArgs, err := buildExifToolWriteArgs(metadata)
|
|
||||||
if err != nil {
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(extraArgs) == 0 {
|
|
||||||
// Nothing to write after filtering. Treat as success so the
|
|
||||||
// caller can move on without a dedicated zero-tag branch.
|
|
||||||
span.SetStatus(codes.Ok, "")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
args := append([]string{"-overwrite_original"}, extraArgs...)
|
|
||||||
args = append(args, inputPath)
|
|
||||||
|
|
||||||
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
|
||||||
if err != nil {
|
|
||||||
err = fmt.Errorf("create ExifTool command: %w", err)
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
exitCode, err := cmd.Exec()
|
|
||||||
if err != nil {
|
|
||||||
err = fmt.Errorf("write PDF metadata with ExifTool (exit %d): %w", exitCode, err)
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
span.SetStatus(codes.Ok, "")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PageCount returns the number of pages in a PDF file using ExifTool.
|
|
||||||
func (engine *ExifTool) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
|
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.PageCount",
|
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
|
||||||
)
|
|
||||||
defer span.End()
|
|
||||||
|
|
||||||
metadata, err := engine.ReadMetadata(ctx, logger, inputPath)
|
|
||||||
if err != nil {
|
|
||||||
err = fmt.Errorf("read metadata with ExifTool: %w", err)
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
pageCountValue, ok := metadata["PageCount"]
|
|
||||||
if !ok {
|
|
||||||
err = errors.New("PageCount not found in metadata")
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
switch val := pageCountValue.(type) {
|
|
||||||
case int:
|
|
||||||
span.SetStatus(codes.Ok, "")
|
|
||||||
return val, nil
|
|
||||||
case int64:
|
|
||||||
span.SetStatus(codes.Ok, "")
|
|
||||||
return int(val), nil
|
|
||||||
case float64:
|
|
||||||
span.SetStatus(codes.Ok, "")
|
|
||||||
return int(val), nil
|
|
||||||
case string:
|
|
||||||
var res int
|
|
||||||
_, err := fmt.Sscanf(val, "%d", &res)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = fmt.Errorf("parse PageCount string '%s': %w", val, err)
|
logger.ErrorContext(ctx, fmt.Sprintf("close ExifTool: %v", err))
|
||||||
|
}
|
||||||
|
}(exifTool)
|
||||||
|
|
||||||
|
fileMetadata := exifTool.ExtractMetadata(inputPath)
|
||||||
|
if fileMetadata[0].Err != nil {
|
||||||
|
err = fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err)
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileMetadata[0].Fields, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteMetadata writes the metadata into a given PDF file.
|
||||||
|
func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
|
||||||
|
_, span := gotenberg.Tracer().Start(ctx, "ExifTool.WriteMetadata", trace.WithSpanKind(trace.SpanKindInternal))
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("new ExifTool: %w", err)
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func(exifTool *exiftool.Exiftool) {
|
||||||
|
err := exifTool.Close()
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorContext(ctx, fmt.Sprintf("close ExifTool: %v", err))
|
||||||
|
}
|
||||||
|
}(exifTool)
|
||||||
|
|
||||||
|
fileMetadata := exifTool.ExtractMetadata(inputPath)
|
||||||
|
if fileMetadata[0].Err != nil {
|
||||||
|
err = fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err)
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define a list of derived, system, or computed tags that ExifTool
|
||||||
|
// extracts but should never be written back. Writing these can break PDF/A
|
||||||
|
// compliance (e.g., PageCount -> prism:pageCount) or cause side effects
|
||||||
|
// (e.g., FileModifyDate).
|
||||||
|
derivedTags := []string{
|
||||||
|
"PageCount", // Causes prism:pageCount injection
|
||||||
|
"Linearized", // Computed status; writing it may invalidate structure
|
||||||
|
"PDFVersion", // Header version; should not be manually forced via metadata
|
||||||
|
"MIMEType", // Read-only derived
|
||||||
|
"FileType", // Read-only derived
|
||||||
|
"FileTypeExtension", // Read-only derived
|
||||||
|
"FileSize", // System attribute
|
||||||
|
"FileModifyDate", // System attribute
|
||||||
|
"FileAccessDate", // System attribute
|
||||||
|
"FileInodeChangeDate", // System attribute
|
||||||
|
"FilePermissions", // System attribute
|
||||||
|
"FileName", // Writing this triggers a file rename in ExifTool
|
||||||
|
"Directory", // System attribute
|
||||||
|
"ExifToolVersion", // Tool metadata
|
||||||
|
"Error", // Extraction error messages
|
||||||
|
"Warning", // Extraction warning messages
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tag := range derivedTags {
|
||||||
|
delete(fileMetadata[0].Fields, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range metadata {
|
||||||
|
switch val := value.(type) {
|
||||||
|
case string:
|
||||||
|
fileMetadata[0].SetString(key, val)
|
||||||
|
case []string:
|
||||||
|
fileMetadata[0].SetStrings(key, val)
|
||||||
|
case []any:
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1048.
|
||||||
|
strs := make([]string, len(val))
|
||||||
|
for i, entry := range val {
|
||||||
|
if str, ok := entry.(string); ok {
|
||||||
|
strs[i] = str
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
err = fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeFor[[]any](), gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fileMetadata[0].SetStrings(key, strs)
|
||||||
|
case bool:
|
||||||
|
fileMetadata[0].SetString(key, fmt.Sprintf("%t", val))
|
||||||
|
case int:
|
||||||
|
fileMetadata[0].SetInt(key, int64(val))
|
||||||
|
case int64:
|
||||||
|
fileMetadata[0].SetInt(key, val)
|
||||||
|
case float32:
|
||||||
|
fileMetadata[0].SetFloat(key, float64(val))
|
||||||
|
case float64:
|
||||||
|
fileMetadata[0].SetFloat(key, val)
|
||||||
|
// TODO: support more complex cases, e.g., arrays and nested objects
|
||||||
|
// (limitations in underlying library).
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeOf(val), gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
||||||
span.RecordError(err)
|
span.RecordError(err)
|
||||||
span.SetStatus(codes.Error, err.Error())
|
span.SetStatus(codes.Error, err.Error())
|
||||||
return 0, err
|
return err
|
||||||
}
|
}
|
||||||
span.SetStatus(codes.Ok, "")
|
}
|
||||||
return res, nil
|
|
||||||
default:
|
exifTool.WriteMetadata(fileMetadata)
|
||||||
err = fmt.Errorf("unexpected PageCount type '%T'", pageCountValue)
|
if fileMetadata[0].Err != nil {
|
||||||
|
err = fmt.Errorf("write PDF metadata with ExifTool: %w", fileMetadata[0].Err)
|
||||||
span.RecordError(err)
|
span.RecordError(err)
|
||||||
span.SetStatus(codes.Error, err.Error())
|
span.SetStatus(codes.Error, err.Error())
|
||||||
return 0, err
|
return err
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// WriteBookmarks is not available in this implementation.
|
return nil
|
||||||
func (engine *ExifTool) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
|
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.WriteBookmarks",
|
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
|
||||||
)
|
|
||||||
defer span.End()
|
|
||||||
|
|
||||||
err := fmt.Errorf("write PDF bookmarks with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadBookmarks is not available in this implementation.
|
|
||||||
func (engine *ExifTool) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
|
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.ReadBookmarks",
|
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
|
||||||
)
|
|
||||||
defer span.End()
|
|
||||||
|
|
||||||
err := fmt.Errorf("read PDF bookmarks with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt is not available in this implementation.
|
// Encrypt is not available in this implementation.
|
||||||
func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
|
func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Encrypt",
|
_, span := gotenberg.Tracer().Start(ctx, "ExifTool.Encrypt", trace.WithSpanKind(trace.SpanKindInternal))
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
|
||||||
)
|
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
err := fmt.Errorf("encrypt PDF using ExifTool: %w", gotenberg.ErrPdfEncryptionNotSupported)
|
err := fmt.Errorf("encrypt PDF using ExifTool: %w", gotenberg.ErrPdfEncryptionNotSupported)
|
||||||
span.RecordError(err)
|
if err != nil {
|
||||||
span.SetStatus(codes.Error, err.Error())
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmbedFiles is not available in this implementation.
|
// AddAttachments is not available in this implementation.
|
||||||
func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
func (engine *ExifTool) AddAttachments(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.EmbedFiles",
|
_, span := gotenberg.Tracer().Start(ctx, "ExifTool.AddAttachments", trace.WithSpanKind(trace.SpanKindInternal))
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
|
||||||
)
|
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
err := fmt.Errorf("embed files with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
err := fmt.Errorf("add attachments with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
span.RecordError(err)
|
if err != nil {
|
||||||
span.SetStatus(codes.Error, err.Error())
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watermark is not available in this implementation.
|
|
||||||
func (engine *ExifTool) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Watermark",
|
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
|
||||||
)
|
|
||||||
defer span.End()
|
|
||||||
|
|
||||||
err := fmt.Errorf("watermark PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stamp is not available in this implementation.
|
|
||||||
func (engine *ExifTool) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Stamp",
|
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
|
||||||
)
|
|
||||||
defer span.End()
|
|
||||||
|
|
||||||
err := fmt.Errorf("stamp PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rotate is not available in this implementation.
|
|
||||||
func (engine *ExifTool) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
|
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Rotate",
|
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
|
||||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
|
||||||
)
|
|
||||||
defer span.End()
|
|
||||||
|
|
||||||
err := fmt.Errorf("rotate PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
|
||||||
span.RecordError(err)
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// EmbedFilesMetadata is not available in this implementation.
|
|
||||||
func (engine *ExifTool) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
|
|
||||||
return fmt.Errorf("set embeds metadata with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interface guards.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ gotenberg.Module = (*ExifTool)(nil)
|
_ gotenberg.Module = (*ExifTool)(nil)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user