Compare commits

..

12 Commits

Author SHA1 Message Date
Julien Neuhart
fb472124c4 feat(chromium): skip or waits for networkIdle2 event 2026-03-11 21:17:09 +01:00
Julien Neuhart
f584fa2dc0 feat(otel): add better metrics for chromium and libreoffice 2026-03-11 20:55:42 +01:00
Julien Neuhart
81d43cfbad fix(Dockerfile): use uid/gid instead of gotenberg in chown 2026-03-11 18:45:50 +01:00
Julien Neuhart
fe72902cdb feat(Dockerfile): reducing size even more 2026-03-11 18:26:43 +01:00
Julien Neuhart
8898bd2b4a refactor(Dockerfile): better Java modules 2026-03-08 16:16:14 +01:00
Julien Neuhart
a62abef215 refactor(Dockerfile): remove fonts 2026-03-08 16:08:33 +01:00
Julien Neuhart
cf9fd7eedc refactor(pdfengines): embeds => attachments 2026-03-08 15:49:09 +01:00
Julien Neuhart
7af3cd1ff5 test(integration): add bookmarks routes 2026-03-04 21:34:16 +01:00
Julien Neuhart
4296474889 feat(pdfengines): add read bookmarks pdfcpu implementation 2026-03-04 20:52:56 +01:00
Julien Neuhart
bfde451e4e feat(pdfengines): add bookmarks read route 2026-03-04 20:48:33 +01:00
Julien Neuhart
fd8c5441d8 feat(pdfengines): add bookmarks write pdfcpu implementation 2026-03-04 20:30:31 +01:00
Julien Neuhart
d7421748d0 feat(pdfengines): add bookmarks write route and update the merge route with this feature 2026-03-04 20:25:06 +01:00
178 changed files with 3297 additions and 14697 deletions

View File

@@ -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"}
}

View File

@@ -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"}
}

View File

@@ -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"}
}

View File

@@ -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"}
}

View File

@@ -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"}
}

View File

@@ -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"}
}

View File

@@ -1,11 +0,0 @@
meta {
name: Debug
type: http
seq: 3
}
get {
url: {{baseUrl}}/debug
body: none
auth: none
}

View File

@@ -1,11 +0,0 @@
meta {
name: Health
type: http
seq: 1
}
get {
url: {{baseUrl}}/health
body: none
auth: none
}

View File

@@ -1,11 +0,0 @@
meta {
name: Prometheus Metrics
type: http
seq: 4
}
get {
url: {{baseUrl}}/prometheus/metrics
body: none
auth: none
}

View File

@@ -1,11 +0,0 @@
meta {
name: Version
type: http
seq: 2
}
get {
url: {{baseUrl}}/version
body: none
auth: none
}

View File

@@ -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"}
}

View File

@@ -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"}
}

View File

@@ -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"}
}

View File

@@ -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"}
}

View File

@@ -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"}
}

View File

@@ -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"}
}

View File

@@ -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"}
}

View File

@@ -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"}
}

View File

@@ -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"}
}

View File

@@ -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"}
}

View File

@@ -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"}
}

View File

@@ -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"}
}

View File

@@ -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"}
}

View File

@@ -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"}
}

View File

@@ -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/`).

View File

@@ -1,6 +0,0 @@
{
"version": "1",
"name": "Gotenberg",
"type": "collection",
"ignore": ["node_modules", ".git"]
}

View File

@@ -1,3 +0,0 @@
headers {
Gotenberg-Trace: {{traceId}}
}

View File

@@ -1,4 +0,0 @@
vars {
baseUrl: https://demo.gotenberg.dev
traceId: bruno-demo
}

View File

@@ -1,4 +0,0 @@
vars {
baseUrl: http://localhost:3000
traceId: bruno-local
}

3
.env
View File

@@ -2,5 +2,6 @@ GOTENBERG_VERSION=snapshot
DOCKER_REGISTRY=gotenberg
DOCKER_REPOSITORY=gotenberg
DOCKERFILE=build/Dockerfile
DOCKERFILE_CLOUDRUN=build/Dockerfile.cloudrun
DOCKERFILE_AWS_LAMBDA=build/Dockerfile.aws-lambda
DOCKER_BUILD_CONTEXT='.'
TARGET=gotenberg

View File

@@ -29,32 +29,14 @@ inputs:
outputs:
tags:
description: Comma separated list of full variant tags
description: Comma separated list of tag
value: ${{ steps.build.outputs.tags }}
tags_chromium:
description: Comma separated list of Chromium variant tags
value: ${{ steps.build.outputs.tags_chromium }}
tags_libreoffice:
description: Comma separated list of LibreOffice variant tags
value: ${{ steps.build.outputs.tags_libreoffice }}
tags_cloud_run:
description: Comma separated list of Cloud Run full variant tags (linux/amd64 only)
description: Comma separated list of Cloud Run tags (linux/amd64 only)
value: ${{ steps.build.outputs.tags_cloud_run }}
tags_cloud_run_chromium:
description: Comma separated list of Cloud Run Chromium variant tags (linux/amd64 only)
value: ${{ steps.build.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice:
description: Comma separated list of Cloud Run LibreOffice variant tags (linux/amd64 only)
value: ${{ steps.build.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda:
description: Comma separated list of AWS Lambda 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 }}
tags_aws_lambda_chromium:
description: Comma separated list of AWS Lambda Chromium variant tags (linux/amd64 and linux/arm64 only)
value: ${{ steps.build.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice:
description: Comma separated list of AWS Lambda LibreOffice variant tags (linux/amd64 and linux/arm64 only)
value: ${{ steps.build.outputs.tags_aws_lambda_libreoffice }}
runs:
using: composite
@@ -66,7 +48,7 @@ runs:
uses: docker/setup-buildx-action@v3
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Log in to Docker Hub
if: inputs.docker_hub_username != ''
@@ -78,53 +60,34 @@ runs:
- name: Build ${{ inputs.platform }}
id: build
shell: bash
env:
INPUT_VERSION: ${{ inputs.version }}
INPUT_PLATFORM: ${{ inputs.platform }}
INPUT_ALTERNATE_REPOSITORY: ${{ inputs.alternate_repository }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
run: |
.github/actions/build-test-push/build.sh \
--version "$INPUT_VERSION" \
--platform "$INPUT_PLATFORM" \
--alternate-repository "$INPUT_ALTERNATE_REPOSITORY" \
--dry-run "$INPUT_DRY_RUN"
--version "${{ inputs.version }}" \
--platform "${{ inputs.platform }}" \
--alternate-repository "${{ inputs.alternate_repository }}" \
--dry-run "${{ inputs.dry_run }}"
- name: Run integration tests
if: inputs.skip_integrations_tests != 'true'
shell: bash
env:
INPUT_VERSION: ${{ inputs.version }}
INPUT_PLATFORM: ${{ inputs.platform }}
INPUT_ALTERNATE_REPOSITORY: ${{ inputs.alternate_repository }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
run: |
.github/actions/build-test-push/test.sh \
--version "$INPUT_VERSION" \
--platform "$INPUT_PLATFORM" \
--alternate-repository "$INPUT_ALTERNATE_REPOSITORY" \
--dry-run "$INPUT_DRY_RUN"
--version "${{ inputs.version }}" \
--platform "${{ inputs.platform }}" \
--alternate-repository "${{ inputs.alternate_repository }}" \
--dry-run "${{ inputs.dry_run }}"
- name: Push
if: inputs.docker_hub_username != ''
shell: bash
env:
INPUT_TAGS: ${{ steps.build.outputs.tags }},${{ steps.build.outputs.tags_chromium }},${{ steps.build.outputs.tags_libreoffice }},${{ steps.build.outputs.tags_cloud_run }},${{ steps.build.outputs.tags_cloud_run_chromium }},${{ steps.build.outputs.tags_cloud_run_libreoffice }},${{ steps.build.outputs.tags_aws_lambda }},${{ steps.build.outputs.tags_aws_lambda_chromium }},${{ steps.build.outputs.tags_aws_lambda_libreoffice }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
run: |
.github/actions/build-test-push/push.sh \
--tags "$INPUT_TAGS" \
--dry-run "$INPUT_DRY_RUN"
--tags "${{ steps.build.outputs.tags }},${{ steps.build.outputs.tags_cloud_run }},${{ steps.build.outputs.tags_aws_lambda }}" \
--dry-run "${{ inputs.dry_run }}"
- name: Outputs
shell: bash
run: |
echo "tags=${{ steps.build.outputs.tags }}"
echo "tags_chromium=${{ steps.build.outputs.tags_chromium }}"
echo "tags_libreoffice=${{ steps.build.outputs.tags_libreoffice }}"
echo "tags_cloud_run=${{ steps.build.outputs.tags_cloud_run }}"
echo "tags_cloud_run_chromium=${{ steps.build.outputs.tags_cloud_run_chromium }}"
echo "tags_cloud_run_libreoffice=${{ steps.build.outputs.tags_cloud_run_libreoffice }}"
echo "tags_aws_lambda=${{ steps.build.outputs.tags_aws_lambda }}"
echo "tags_aws_lambda_chromium=${{ steps.build.outputs.tags_aws_lambda_chromium }}"
echo "tags_aws_lambda_libreoffice=${{ steps.build.outputs.tags_aws_lambda_libreoffice }}"

View File

@@ -55,14 +55,8 @@ fi
# Build tags arrays.
tags=()
tags_chromium=()
tags_libreoffice=()
tags_cloud_run=()
tags_cloud_run_chromium=()
tags_cloud_run_libreoffice=()
tags_aws_lambda=()
tags_aws_lambda_chromium=()
tags_aws_lambda_libreoffice=()
IFS='/' read -ra arch <<< "$platform"
IFS='.' read -ra semver <<< "$version"
@@ -75,95 +69,55 @@ if [ "${#semver[@]}" -eq 3 ]; then
minor="${semver[1]}"
patch="${semver[2]}"
for suffix in "latest" "$major" "$major.$minor" "$major.$minor.$patch"; do
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-${arch[1]}")
tags_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-chromium-${arch[1]}")
tags_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-libreoffice-${arch[1]}")
done
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-${arch[1]}")
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major-${arch[1]}")
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor-${arch[1]}")
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor.$patch-${arch[1]}")
if [ "$platform" = "linux/amd64" ]; then
for suffix in "latest" "$major" "$major.$minor" "$major.$minor.$patch"; do
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-cloudrun")
tags_cloud_run_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-chromium-cloudrun")
tags_cloud_run_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-libreoffice-cloudrun")
done
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-cloudrun")
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major-cloudrun")
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor-cloudrun")
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor.$patch-cloudrun")
fi
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:$suffix-aws-lambda-${arch[1]}")
tags_aws_lambda_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-chromium-aws-lambda-${arch[1]}")
tags_aws_lambda_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-libreoffice-aws-lambda-${arch[1]}")
done
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-aws-lambda-${arch[1]}")
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major-aws-lambda-${arch[1]}")
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor-aws-lambda-${arch[1]}")
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor.$patch-aws-lambda-${arch[1]}")
fi
else
echo
echo "Non-semver version detected, fallback to $version"
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-${arch[1]}")
tags_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-chromium-${arch[1]}")
tags_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-libreoffice-${arch[1]}")
if [ "$platform" = "linux/amd64" ]; then
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-cloudrun")
tags_cloud_run_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-chromium-cloudrun")
tags_cloud_run_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-libreoffice-cloudrun")
fi
if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-aws-lambda-${arch[1]}")
tags_aws_lambda_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-chromium-aws-lambda-${arch[1]}")
tags_aws_lambda_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-libreoffice-aws-lambda-${arch[1]}")
fi
fi
tags_flags=()
tags_chromium_flags=()
tags_libreoffice_flags=()
tags_cloud_run_flags=()
tags_cloud_run_chromium_flags=()
tags_cloud_run_libreoffice_flags=()
tags_aws_lambda_flags=()
tags_aws_lambda_chromium_flags=()
tags_aws_lambda_libreoffice_flags=()
echo "Will use the following tags:"
for tag in "${tags[@]}"; do
tags_flags+=("-t" "$tag")
echo "- $tag"
done
for tag in "${tags_chromium[@]}"; do
tags_chromium_flags+=("-t" "$tag")
echo "- $tag"
done
for tag in "${tags_libreoffice[@]}"; do
tags_libreoffice_flags+=("-t" "$tag")
echo "- $tag"
done
for tag in "${tags_cloud_run[@]}"; do
tags_cloud_run_flags+=("-t" "$tag")
echo "- $tag"
done
for tag in "${tags_cloud_run_chromium[@]}"; do
tags_cloud_run_chromium_flags+=("-t" "$tag")
echo "- $tag"
done
for tag in "${tags_cloud_run_libreoffice[@]}"; do
tags_cloud_run_libreoffice_flags+=("-t" "$tag")
echo "- $tag"
done
for tag in "${tags_aws_lambda[@]}"; do
tags_aws_lambda_flags+=("-t" "$tag")
echo "- $tag"
done
for tag in "${tags_aws_lambda_chromium[@]}"; do
tags_aws_lambda_chromium_flags+=("-t" "$tag")
echo "- $tag"
done
for tag in "${tags_aws_lambda_libreoffice[@]}"; do
tags_aws_lambda_libreoffice_flags+=("-t" "$tag")
echo "- $tag"
done
echo
# Build images.
@@ -191,9 +145,7 @@ join() {
no_arch_tag="$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version"
# Full variant.
cmd="docker buildx build \
--target gotenberg \
--build-arg GOTENBERG_VERSION=$version \
--platform $platform \
--load \
@@ -203,102 +155,30 @@ cmd="docker buildx build \
"
run_cmd "$cmd"
# Chromium only variant.
cmd="docker buildx build \
--target gotenberg-chromium \
--build-arg GOTENBERG_VERSION=$version \
--platform $platform \
--load \
${tags_chromium_flags[*]} \
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
"
run_cmd "$cmd"
# LibreOffice only variant.
cmd="docker buildx build \
--target gotenberg-libreoffice \
--build-arg GOTENBERG_VERSION=$version \
--platform $platform \
--load \
${tags_libreoffice_flags[*]} \
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
"
run_cmd "$cmd"
# Cloud Run variants (amd64 only).
if [ "$platform" = "linux/amd64" ]; then
cmd="docker buildx build \
--target gotenberg-cloudrun \
cmd="docker build \
--build-arg DOCKER_REGISTRY=$DOCKER_REGISTRY \
--build-arg DOCKER_REPOSITORY=$DOCKER_REPOSITORY \
--build-arg GOTENBERG_VERSION=$version \
--platform $platform \
--load \
${tags_cloud_run_flags[*]} \
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
"
run_cmd "$cmd"
cmd="docker buildx build \
--target gotenberg-cloudrun-chromium \
--build-arg GOTENBERG_VERSION=$version \
--platform $platform \
--load \
${tags_cloud_run_chromium_flags[*]} \
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
"
run_cmd "$cmd"
cmd="docker buildx build \
--target gotenberg-cloudrun-libreoffice \
--build-arg GOTENBERG_VERSION=$version \
--platform $platform \
--load \
${tags_cloud_run_libreoffice_flags[*]} \
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
-f $DOCKERFILE_CLOUDRUN $DOCKER_BUILD_CONTEXT
"
run_cmd "$cmd"
fi
# AWS Lambda variants (amd64 + arm64 only).
if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then
cmd="docker buildx build \
--target gotenberg-aws-lambda \
--build-arg GOTENBERG_VERSION=$version \
--platform $platform \
--load \
${tags_aws_lambda_flags[*]} \
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
"
run_cmd "$cmd"
cmd="docker buildx build \
--target gotenberg-aws-lambda-chromium \
--build-arg GOTENBERG_VERSION=$version \
--platform $platform \
--load \
${tags_aws_lambda_chromium_flags[*]} \
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
"
run_cmd "$cmd"
cmd="docker buildx build \
--target gotenberg-aws-lambda-libreoffice \
--build-arg GOTENBERG_VERSION=$version \
--platform $platform \
--load \
${tags_aws_lambda_libreoffice_flags[*]} \
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
cmd="docker build \
--build-arg DOCKER_REGISTRY=$DOCKER_REGISTRY \
--build-arg DOCKER_REPOSITORY=$DOCKER_REPOSITORY \
--build-arg GOTENBERG_VERSION=$version \
${tags_aws_lambda_flags[*]} \
-f $DOCKERFILE_AWS_LAMBDA $DOCKER_BUILD_CONTEXT
"
run_cmd "$cmd"
fi
echo "✅ Done!"
echo "tags=$(join "," "${tags[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_chromium=$(join "," "${tags_chromium[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_libreoffice=$(join "," "${tags_libreoffice[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_cloud_run=$(join "," "${tags_cloud_run[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_cloud_run_chromium=$(join "," "${tags_cloud_run_chromium[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_cloud_run_libreoffice=$(join "," "${tags_cloud_run_libreoffice[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_aws_lambda=$(join "," "${tags_aws_lambda[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_aws_lambda_chromium=$(join "," "${tags_aws_lambda_chromium[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_aws_lambda_libreoffice=$(join "," "${tags_aws_lambda_libreoffice[@]}")" >> "$GITHUB_OUTPUT"
exit 0

View File

@@ -23,12 +23,9 @@ runs:
env:
DOCKERHUB_USERNAME: ${{ inputs.docker_hub_username }}
DOCKERHUB_TOKEN: ${{ inputs.docker_hub_password }}
INPUT_TAGS: ${{ inputs.tags }}
INPUT_SNAPSHOT_VERSION: ${{ inputs.snapshot_version }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
shell: bash
run: |
.github/actions/clean/clean.sh \
--tags "$INPUT_TAGS" \
--snapshot-version "$INPUT_SNAPSHOT_VERSION" \
--dry-run "$INPUT_DRY_RUN"
--tags "${{ inputs.tags }}" \
--snapshot-version "${{ inputs.snapshot_version }}" \
--dry-run "${{ inputs.dry_run }}"

View File

@@ -31,7 +31,7 @@ runs:
uses: docker/setup-buildx-action@v3
- name: Check out code
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Log in to Docker Hub
uses: docker/login-action@v3
@@ -41,12 +41,8 @@ runs:
- name: Merge
shell: bash
env:
INPUT_TAGS: ${{ inputs.tags }}
INPUT_ALTERNATE_REGISTRY: ${{ inputs.alternate_registry }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
run: |
.github/actions/merge/merge.sh \
--tags "$INPUT_TAGS" \
--alternate-registry "$INPUT_ALTERNATE_REGISTRY" \
--dry-run "$INPUT_DRY_RUN"
--tags "${{ inputs.tags }}" \
--alternate-registry "${{ inputs.alternate_registry }}" \
--dry-run "${{ inputs.dry_run }}"

View File

@@ -16,5 +16,3 @@ updates:
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 7

View File

@@ -13,14 +13,8 @@ jobs:
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.build_push.outputs.tags }}
tags_chromium: ${{ steps.build_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -40,14 +34,8 @@ jobs:
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.build_push.outputs.tags }}
tags_chromium: ${{ steps.build_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -67,14 +55,8 @@ jobs:
runs-on: ubuntu-24.04-ppc64le
outputs:
tags: ${{ steps.build_push.outputs.tags }}
tags_chromium: ${{ steps.build_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -94,14 +76,8 @@ jobs:
runs-on: ubuntu-24.04-arm
outputs:
tags: ${{ steps.build_push.outputs.tags }}
tags_chromium: ${{ steps.build_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -121,14 +97,8 @@ jobs:
runs-on: ubuntu-24.04-arm
outputs:
tags: ${{ steps.build_push.outputs.tags }}
tags_chromium: ${{ steps.build_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -162,20 +132,7 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_ppc64le.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }}"
- 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 }}"
alternate_registry: thecodingmachine
- name: Merge AWS Lambda
uses: ./.github/actions/merge
@@ -183,24 +140,11 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags_aws_lambda }},${{ needs.release_arm64.outputs.tags_aws_lambda }}"
- 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 }}"
alternate_registry: thecodingmachine
- name: Clean
uses: ./.github/actions/clean
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_ppc64le.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }},${{ needs.release_amd64.outputs.tags_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 }}"

View File

@@ -46,7 +46,7 @@ jobs:
node-version-file: .node-version
- name: Install Dependencies
run: npm ci --ignore-scripts
run: npm i
- name: Run linters
run: make lint-prettier
@@ -77,14 +77,8 @@ jobs:
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -107,14 +101,8 @@ jobs:
runs-on: ubuntu-24.04-ppc64le
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -137,14 +125,8 @@ jobs:
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -167,14 +149,8 @@ jobs:
runs-on: ubuntu-24.04-arm
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -197,14 +173,8 @@ jobs:
runs-on: ubuntu-24.04-arm
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -262,20 +232,6 @@ jobs:
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.snapshot_amd64.outputs.tags }},${{ needs.snapshot_386.outputs.tags }},${{ needs.snapshot_ppc64le.outputs.tags }},${{ needs.snapshot_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }}"
- name: Merge Chromium
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.snapshot_amd64.outputs.tags_chromium }},${{ needs.snapshot_386.outputs.tags_chromium }},${{ needs.snapshot_ppc64le.outputs.tags_chromium }},${{ needs.snapshot_arm64.outputs.tags_chromium }},${{ needs.snapshot_arm_v7.outputs.tags_chromium }}"
- name: Merge LibreOffice
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.snapshot_amd64.outputs.tags_libreoffice }},${{ needs.snapshot_386.outputs.tags_libreoffice }},${{ needs.snapshot_ppc64le.outputs.tags_libreoffice }},${{ needs.snapshot_arm64.outputs.tags_libreoffice }},${{ needs.snapshot_arm_v7.outputs.tags_libreoffice }}"
- name: Merge AWS Lambda
uses: ./.github/actions/merge
with:
@@ -283,26 +239,12 @@ jobs:
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.snapshot_amd64.outputs.tags_aws_lambda }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda }}"
- name: Merge AWS Lambda Chromium
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.snapshot_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda_chromium }}"
- name: Merge AWS Lambda LibreOffice
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.snapshot_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda_libreoffice }}"
- name: Clean
uses: ./.github/actions/clean
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.snapshot_amd64.outputs.tags }},${{ needs.snapshot_386.outputs.tags }},${{ needs.snapshot_ppc64le.outputs.tags }},${{ needs.snapshot_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }},${{ needs.snapshot_amd64.outputs.tags_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:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
@@ -312,14 +254,8 @@ jobs:
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -341,14 +277,8 @@ jobs:
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -370,14 +300,8 @@ jobs:
runs-on: ubuntu-24.04-ppc64le
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -399,14 +323,8 @@ jobs:
runs-on: ubuntu-24.04-arm
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -428,14 +346,8 @@ jobs:
runs-on: ubuntu-24.04-arm
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps:
- name: Checkout source code
uses: actions/checkout@v6
@@ -468,20 +380,7 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_ppc64le.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }}"
- 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 }}"
alternate_registry: thecodingmachine
- name: Merge AWS Lambda
uses: ./.github/actions/merge
@@ -489,24 +388,11 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags_aws_lambda }},${{ needs.edge_arm64.outputs.tags_aws_lambda }}"
- 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 }}"
alternate_registry: thecodingmachine
- name: Clean
uses: ./.github/actions/clean
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_ppc64le.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }},${{ needs.edge_amd64.outputs.tags_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 }}"

View File

@@ -24,14 +24,13 @@ linters:
- ineffassign
- misspell
- prealloc
- promlinter
- staticcheck
- testableexamples
- tparallel
- unconvert
- unused
- sloglint
- usetesting
- gocritic
- wastedassign
- whitespace
exclusions:
@@ -41,6 +40,10 @@ linters:
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gci
@@ -56,3 +59,7 @@ formatters:
custom-order: true
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

View File

@@ -1 +1 @@
24.15.0
24.11.0

1
.npmrc
View File

@@ -1 +0,0 @@
min-release-age=7

View File

@@ -1 +0,0 @@
CONTRIBUTING.md

66
AGENTS.md Normal file
View 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.

View File

@@ -1 +0,0 @@
CONTRIBUTING.md

View File

@@ -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).

View File

@@ -1 +0,0 @@
CONTRIBUTING.md

View File

@@ -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}'
.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 \
--target $(TARGET) \
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
-f $(DOCKERFILE) $(DOCKER_BUILD_CONTEXT)
@@ -27,17 +26,16 @@ API_ENABLE_BASIC_AUTH=false
GOTENBERG_API_BASIC_AUTH_USERNAME=
GOTENBERG_API_BASIC_AUTH_PASSWORD=
API_DOWNLOAD_FROM_ALLOW_LIST=
API_DOWNLOAD_FROM_DENY_LIST=^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_DISABLE_DOWNLOAD_FROM=false
API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=true
API_DISABLE_ROOT_ROUTE_TELEMETRY=true
API_DISABLE_DEBUG_ROUTE_TELEMETRY=true
API_DISABLE_VERSION_ROUTE_TELEMETRY=true
API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=false
API_DISABLE_ROOT_ROUTE_TELEMETRY=false
API_DISABLE_VERSION_ROUTE_TELEMETRY=false
API_DISABLE_DEBUG_ROUTE_TELEMETRY=false
API_ENABLE_DEBUG_ROUTE=false
CHROMIUM_RESTART_AFTER=100
CHROMIUM_MAX_QUEUE_SIZE=0
CHROMIUM_IDLE_SHUTDOWN_TIMEOUT=0
CHROMIUM_MAX_CONCURRENCY=6
CHROMIUM_AUTO_START=false
CHROMIUM_START_TIMEOUT=20s
@@ -55,36 +53,22 @@ CHROMIUM_DISABLE_JAVASCRIPT=false
CHROMIUM_DISABLE_ROUTES=false
LIBREOFFICE_RESTART_AFTER=10
LIBREOFFICE_MAX_QUEUE_SIZE=0
LIBREOFFICE_IDLE_SHUTDOWN_TIMEOUT=0
LIBREOFFICE_AUTO_START=false
LIBREOFFICE_START_TIMEOUT=20s
LIBREOFFICE_ALLOW_LIST=
LIBREOFFICE_DENY_LIST=
LIBREOFFICE_DISABLE_ROUTES=false
LOG_LEVEL=info
LOG_FIELDS_PREFIX=
LOG_STD_FORMAT=auto
LOG_STD_ENABLE_GCP_FIELDS=false
PDFENGINES_DISABLE_ROUTES=false
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
PDFENGINES_FLATTEN_ENGINES=qpdf
PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
PDFENGINES_READ_METADATA_ENGINES=exiftool
PDFENGINES_WRITE_METADATA_ENGINES=exiftool
PDFENGINES_READ_BOOKMARKS_ENGINES=pdfcpu
PDFENGINES_WRITE_BOOKMARKS_ENGINES=pdfcpu
PDFENGINES_WATERMARK_ENGINES=pdfcpu,pdftk
PDFENGINES_STAMP_ENGINES=pdfcpu,pdftk
PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
PDFENGINES_EMBED_ENGINES=qpdf,pdfcpu
PDFENGINES_EMBED_METADATA_ENGINES=qpdf
PROMETHEUS_NAMESPACE=gotenberg
PROMETHEUS_COLLECT_INTERVAL=1s
PROMETHEUS_DISABLE_ROUTE_TELEMETRY=true
PROMETHEUS_DISABLE_COLLECT=false
PROMETHEUS_METRICS_PATH=/prometheus/metrics
PDFENGINES_DISABLE_ROUTES=false
PDFENGINES_ADD_ATTACHMENTS_ENGINES=pdfcpu
OTEL_SERVICE_NAME=gotenberg
OTEL_TRACES_EXPORTER=none
OTEL_METRICS_EXPORTER=none
@@ -94,7 +78,9 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
OTEL_EXPORTER_OTLP_INSECURE=true
WEBHOOK_ENABLE_SYNC_MODE=false
WEBHOOK_ALLOW_LIST=
WEBHOOK_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)
WEBHOOK_DENY_LIST=
WEBHOOK_ERROR_ALLOW_LIST=
WEBHOOK_ERROR_DENY_LIST=
WEBHOOK_MAX_RETRY=4
WEBHOOK_RETRY_MIN_WAIT=1s
WEBHOOK_RETRY_MAX_WAIT=30s
@@ -128,9 +114,6 @@ NO_CONCURRENCY=false
# chromium-convert-html
# chromium-convert-markdown
# chromium-convert-url
# chromium-screenshot-html
# chromium-screenshot-markdown
# chromium-screenshot-url
# debug
# health
# libreoffice
@@ -138,8 +121,8 @@ NO_CONCURRENCY=false
# output-filename
# pdfengines
# pdfengines-convert
# pdfengines-embed
# embed
# pdfengines-attachments
# attachments
# pdfengines-encrypt
# encrypt
# pdfengines-flatten
@@ -150,23 +133,15 @@ NO_CONCURRENCY=false
# metadata
# pdfengines-split
# split
# pdfengines-watermark
# watermark
# pdfengines-stamp
# stamp
# pdfengines-rotate
# rotate
# pdfengines-bookmarks
# bookmarks
# prometheus-metrics
# root
# version
# webhook
# download-from
# telemetry
TAGS=
.PHONY: test-integration
test-integration: ## Run integration tests (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 \
--gotenberg-docker-repository=$(DOCKER_REPOSITORY) \
--gotenberg-version=$(GOTENBERG_VERSION) \

View File

@@ -1,7 +1,7 @@
<p align="center">
<img src="https://user-images.githubusercontent.com/8983173/130322857-185831e2-f041-46eb-a17f-0a69d066c4e5.png" alt="Gotenberg Logo" width="150" height="150" />
<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">
<a href="https://hub.docker.com/r/gotenberg/gotenberg"><img alt="Total downloads (gotenberg/gotenberg)" src="https://img.shields.io/docker/pulls/gotenberg/gotenberg"></a>
<a href="https://github.com/gotenberg/gotenberg/actions/workflows/continuous-integration.yml"><img alt="Continuous Integration" src="https://github.com/gotenberg/gotenberg/actions/workflows/continuous-integration.yml/badge.svg"></a>
@@ -10,60 +10,38 @@
<p align="center">
<a href="https://trendshift.io/repositories/2996"><img src="https://trendshift.io/api/badge/repositories/2996" alt="gotenberg%2Fgotenberg | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<p align="center">
<a href="https://gotenberg.dev/docs/getting-started/introduction"><strong>Documentation</strong></a> &#183;
<a href="https://gotenberg.dev/docs/getting-started/installation#live-demo"><strong>Live Demo</strong></a> &#183;
<a href="https://github.com/gotenberg/gotenberg/discussions"><strong>Community</strong></a>
</p>
<p align="center"><a href="https://gotenberg.dev/docs/getting-started/introduction">Read the Documentation</a> &#183; <a href="https://gotenberg.dev/docs/getting-started/installation#live-demo-">Try the Live Demo</a> 🔥</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
Open a terminal and run the following command:
```bash
docker run --rm -p 3000:3000 gotenberg/gotenberg:8
```
Convert a URL to PDF:
```bash
curl \
--request POST http://localhost:3000/forms/chromium/convert/url \
--form url=https://sparksuite.github.io/simple-html-invoice-template/ \
-o invoice.pdf
```
## Features
- **HTML, URL, Markdown to PDF** via Headless Chromium
- **Office documents to PDF** via LibreOffice (100+ formats)
- **Merge, split, rotate, flatten** PDFs
- **Watermark, stamp, encrypt** PDFs
- **PDF/A and PDF/UA** compliance
- **Screenshots** of URLs and HTML
- **Read/write metadata and bookmarks**
See the [full documentation](https://gotenberg.dev/docs/getting-started/introduction).
## Contributing
Questions and feedback: [GitHub Discussions](https://github.com/gotenberg/gotenberg/discussions).
Bug reports: [GitHub Issues](https://github.com/gotenberg/gotenberg/issues).
With the API running at `http://localhost:3000`, you are now ready to head
to the **[Full Documentation](https://gotenberg.dev/docs/getting-started/introduction)** to discover how to convert URLs,
local files, inject custom CSS, merge PDFs, and more.
## 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**
- [TheCodingMachine](https://thecodingmachine.com/)
- [pdfme](https://pdfme.com/)
- [PDFBolt](https://pdfbolt.com)
**Powered By**

View File

@@ -2,28 +2,40 @@
## 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
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.
- Steps to reproduce the issue.
- Potential impact on users or the system.
- The steps to reproduce the issue.
- 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
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.
- The reporter will be notified when the fix is released.
- The reporter will be credited for the discovery (unless anonymity is requested).
- Work on a fix and a release timeline.
- Notify you when the fix has been implemented and released.
- Credit you for discovering the vulnerability (unless you request anonymity).
- Please note that we will do our best to keep you informed about the progress towards resolving the issue.
## 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.

View File

@@ -1,7 +1,7 @@
# ARG instructions do not create additional layers. Instead, next layers will
# concatenate them. Also, we have to repeat ARG instructions in each build
# stage that uses them.
ARG GOLANG_VERSION=1.26.2
ARG GOLANG_VERSION=1.26.0
# ----------------------------------------------
# pdfcpu binary build stage
@@ -11,22 +11,22 @@ ARG GOLANG_VERSION=1.26.2
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
# See https://github.com/pdfcpu/pdfcpu/releases.
ARG PDFCPU_VERSION=v0.12.0
ARG PDFCPU_VERSION=v0.11.1
ENV CGO_ENABLED=0
# Define the working directory outside of $GOPATH (we're using go modules).
WORKDIR /home
RUN curl -Ls "https://github.com/pdfcpu/pdfcpu/archive/refs/tags/$PDFCPU_VERSION.tar.gz" -o pdfcpu.tar.gz \
&& tar --strip-components=1 -xvzf pdfcpu.tar.gz
RUN curl -Ls "https://github.com/pdfcpu/pdfcpu/archive/refs/tags/$PDFCPU_VERSION.tar.gz" -o pdfcpu.tar.gz &&\
tar --strip-components=1 -xvzf pdfcpu.tar.gz
# Install module dependencies.
RUN go mod download \
&& go mod verify
RUN go mod download &&\
go mod verify
RUN go build -o pdfcpu -ldflags "-s -w -X 'main.version=$PDFCPU_VERSION' -X 'github.com/pdfcpu/pdfcpu/pkg/pdfcpu/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.
&& ./pdfcpu version
./pdfcpu version
# ----------------------------------------------
# Gotenberg binary build stage
@@ -42,16 +42,14 @@ WORKDIR /home
# Install module dependencies.
COPY go.mod go.sum ./
RUN go mod download \
&& go mod verify
RUN go mod download &&\
go mod verify
# Copy the source code.
COPY cmd ./cmd
COPY pkg ./pkg
RUN go build -o gotenberg -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go \
&& 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
RUN go build -o gotenberg -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go
# ----------------------------------------------
# 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
RUN apt-get update -qq \
&& apt-get upgrade -yqq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends default-jdk-headless binutils
RUN \
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends default-jdk-headless binutils
# Note: jdeps helps finding which modules a JAR requires.
# Currently only for PDFtk, as we don't rely on LibreOffice UNO Java SDK.
ENV JAVA_MODULES=java.base,java.desktop,java.naming,java.sql
ENV JAVA_MODULES=java.base,java.desktop,java.xml
RUN jlink \
--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.
ARG PDFTK_VERSION=v3.3.3
RUN apt-get update -qq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl ca-certificates
RUN apt-get update -qq && \
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl ca-certificates
WORKDIR /downloads
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.2.0/unoconv -o unoconverter \
&& chmod +x unoconverter
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.2.0/unoconv -o unoconverter &&\
chmod +x unoconverter
RUN curl -o pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" \
&& chmod a+x pdftk-all.jar
RUN curl -o pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" &&\
chmod a+x pdftk-all.jar
# ----------------------------------------------
# Base image stage
@@ -101,33 +100,48 @@ FROM debian:13-slim AS base-image-stage
ARG TIMEZONE=UTC
ENV TZ=$TIMEZONE
# Set default characterset encoding to UTF-8.
# See:
# https://github.com/gotenberg/gotenberg/issues/104
# https://github.com/gotenberg/gotenberg/issues/730
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
COPY --link --from=custom-jre-stage /custom-jre /opt/java
ENV PATH="/opt/java/bin:${PATH}"
# ----------------------------------------------
# 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_UID=1001
# Create a non-root user.
# All processes in the Docker container will run with this dedicated user.
RUN groupadd --gid "$GOTENBERG_USER_GID" gotenberg \
&& useradd --uid "$GOTENBERG_USER_UID" --gid gotenberg --shell /bin/bash --home /home/gotenberg --no-create-home gotenberg \
&& mkdir /home/gotenberg \
&& chown gotenberg: /home/gotenberg
LABEL org.opencontainers.image.title="Gotenberg" \
org.opencontainers.image.description="A containerized API for seamless PDF conversion." \
org.opencontainers.image.version="$GOTENBERG_VERSION" \
org.opencontainers.image.authors="Julien Neuhart <neuhart.julien@gmail.com>" \
org.opencontainers.image.documentation="https://gotenberg.dev" \
org.opencontainers.image.source="https://github.com/gotenberg/gotenberg"
# Note: tini is a helper for reaping zombie processes.
RUN apt-get update -qq \
&& apt-get upgrade -yqq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
tini \
# Many users rely on curl for Docker health checks.
curl \
RUN \
# Create a non-root user.
# All processes in the Docker container will run with this dedicated user.
groupadd --gid "$GOTENBERG_USER_GID" gotenberg &&\
useradd --uid "$GOTENBERG_USER_UID" --gid gotenberg --shell /bin/bash --home /home/gotenberg --no-create-home gotenberg &&\
mkdir /home/gotenberg &&\
chown gotenberg: /home/gotenberg
RUN \
echo "deb http://deb.debian.org/debian trixie-backports main" >> /etc/apt/sources.list &&\
apt-get update -qq &&\
apt-get upgrade -yqq &&\
# Note: tini is a helper for reaping zombie processes.
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
tini python3 python3-distutils-extra \
# Install fonts.
# Essential metric-compatible fonts for LibreOffice layout fidelity (replaces MS Fonts).
fonts-crosextra-carlito \
@@ -140,29 +154,46 @@ RUN apt-get update -qq \
fonts-noto-cjk \
# Standard Emoji support.
fonts-noto-color-emoji \
# Tofu prevention.
fonts-noto-core \
# Install Hyphenation for LibreOffice.
# Credits: https://wiki.archlinux.org/title/LibreOffice.
hyphen-af hyphen-as hyphen-be hyphen-bg hyphen-bn hyphen-ca hyphen-cs hyphen-da hyphen-de hyphen-el \
hyphen-en-gb hyphen-en-us hyphen-eo hyphen-es hyphen-fr hyphen-gl hyphen-gu hyphen-hi hyphen-hr hyphen-hu \
hyphen-id hyphen-is hyphen-it hyphen-kn hyphen-lt hyphen-lv hyphen-ml hyphen-mn hyphen-mr hyphen-nl \
hyphen-no hyphen-or hyphen-pa hyphen-pl hyphen-pt-br hyphen-pt-pt hyphen-ro hyphen-ru hyphen-sk hyphen-sl \
hyphen-sr hyphen-sv hyphen-ta hyphen-te hyphen-th hyphen-uk hyphen-zu \
# Install Chromium.
chromium \
# 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.
&& mkdir -p /usr/share/man/man1 \
mkdir -p /usr/share/man/man1 &&\
# 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.
# See https://github.com/gotenberg/gotenberg/pull/273.
RUN printf '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk \
&& chmod +x /usr/bin/pdftk
# Copy downloaded artifacts (unoconverter & PDFtk).
COPY --link --from=downloader-stage /downloads/unoconverter /usr/bin/unoconverter
COPY --link --from=downloader-stage /downloads/pdftk-all.jar /usr/bin/pdftk-all.jar
RUN \
# Install PDFtk bash wrapper.
# See https://github.com/gotenberg/gotenberg/pull/273.
printf '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk && \
chmod +x /usr/bin/pdftk
# Support for arbitrary user IDs (OpenShift).
# See:
# https://github.com/gotenberg/gotenberg/issues/1049.
# https://docs.redhat.com/en/documentation/openshift_container_platform/4.15/html/images/creating-images#use-uid_create-images.
RUN usermod -aG root gotenberg \
&& chgrp -R 0 /home/gotenberg \
&& chmod -R g=u /home/gotenberg
# COPY instructions last to maximize cache reuse when only source code changes.
RUN \
usermod -aG root gotenberg &&\
chgrp -R 0 /home/gotenberg &&\
chmod -R g=u /home/gotenberg
# Improve fonts subpixel hinting and smoothing.
# Credits:
@@ -170,104 +201,24 @@ RUN usermod -aG root gotenberg \
# https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d.
COPY --link build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf
# Copy PDFtk jar.
COPY --link --from=downloader-stage /downloads/pdftk-all.jar /usr/bin/pdftk-all.jar
# Copy dictionnaries so that hypens work on Chromium.
# See https://github.com/gotenberg/gotenberg/issues/1293.
COPY --link --chown="$GOTENBERG_USER_UID:$GOTENBERG_USER_GID" build/chromium-hyphen-data /opt/gotenberg/chromium-hyphen-data
# Copy the pdfcpu binary (shared across all variants).
# Copy the Golang binaries.
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.
# See:
# https://github.com/gotenberg/gotenberg/issues/104
# https://github.com/gotenberg/gotenberg/issues/730
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
# Environment variables for PDF engines.
# Environment variables required by modules or else.
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
ENV CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
ENV PDFTK_BIN_PATH=/usr/bin/pdftk
ENV QPDF_BIN_PATH=/usr/bin/qpdf
ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool
ENV PDFCPU_BIN_PATH=/usr/bin/pdfcpu
# OpenTelemetry defaults (noop - no telemetry overhead unless explicitly enabled).
ENV OTEL_TRACES_EXPORTER=none
ENV OTEL_METRICS_EXPORTER=none
ENV OTEL_LOGS_EXPORTER=none
# ----------------------------------------------
# Final stage: full (Chromium + LibreOffice)
# ----------------------------------------------
FROM common-stage AS gotenberg
ARG GOTENBERG_VERSION=snapshot
ARG GOTENBERG_USER_GID=1001
ARG GOTENBERG_USER_UID=1001
ARG TMP_CHROMIUM_VERSION_PPC64EL="146.0.7680.80-1~deb13u1"
LABEL org.opencontainers.image.title="Gotenberg" \
org.opencontainers.image.description="A Docker-based API for converting documents to PDF." \
org.opencontainers.image.version="$GOTENBERG_VERSION" \
org.opencontainers.image.authors="Julien Neuhart <neuhart.julien@gmail.com>" \
org.opencontainers.image.documentation="https://gotenberg.dev" \
org.opencontainers.image.source="https://github.com/gotenberg/gotenberg"
# Install Chromium.
# On ppc64el, the latest Chromium is broken, so we pin a known working
# version from snapshot.debian.org via debsnap.
RUN /bin/bash -c \
'set -e &&\
if [[ "$(dpkg --print-architecture)" == "ppc64el" ]]; then \
apt-get update -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends devscripts &&\
debsnap chromium-common "$TMP_CHROMIUM_VERSION_PPC64EL" -v --force --binary --architecture ppc64el &&\
debsnap chromium "$TMP_CHROMIUM_VERSION_PPC64EL" -v --force --binary --architecture ppc64el &&\
DEBIAN_FRONTEND=noninteractive apt-get install --fix-broken -y -qq --no-install-recommends "./binary-chromium-common/chromium-common_${TMP_CHROMIUM_VERSION_PPC64EL}_ppc64el.deb" "./binary-chromium/chromium_${TMP_CHROMIUM_VERSION_PPC64EL}_ppc64el.deb" &&\
DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq devscripts &&\
rm -rf ./binary-chromium-common/* ./binary-chromium/*; \
else \
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \
fi' \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Install LibreOffice & unoconverter.
RUN echo "deb http://deb.debian.org/debian trixie-backports main" >> /etc/apt/sources.list \
&& apt-get update -qq \
&& apt-get upgrade -yqq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
python3 python3-distutils-extra \
# Install Hyphenation for LibreOffice.
# Credits: https://wiki.archlinux.org/title/LibreOffice.
hyphen-af hyphen-as hyphen-be hyphen-bg hyphen-bn hyphen-ca hyphen-cs hyphen-da hyphen-de hyphen-el \
hyphen-en-gb hyphen-en-us hyphen-eo hyphen-es hyphen-fr hyphen-gl hyphen-gu hyphen-hi hyphen-hr hyphen-hu \
hyphen-id hyphen-is hyphen-it hyphen-kn hyphen-lt hyphen-lv hyphen-ml hyphen-mn hyphen-mr hyphen-nl \
hyphen-no hyphen-or hyphen-pa hyphen-pl hyphen-pt-br hyphen-pt-pt hyphen-ro hyphen-ru hyphen-sk hyphen-sl \
hyphen-sr hyphen-sv hyphen-ta hyphen-te hyphen-th hyphen-uk hyphen-zu \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports \
libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw python3-uno \
# unoconverter will look for the Python binary, which has to be at version 3.
&& ln -s /usr/bin/python3 /usr/bin/python \
# Cleanup.
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# COPY instructions last to maximize cache reuse.
# Copy the Gotenberg binary (full variant with all modules).
COPY --link --from=gotenberg-binary-stage /home/gotenberg /usr/bin/
# Copy unoconverter.
COPY --link --from=downloader-stage /downloads/unoconverter /usr/bin/unoconverter
# Copy dictionnaries so that hyphens work on Chromium.
# See https://github.com/gotenberg/gotenberg/issues/1293.
COPY --link --chown="$GOTENBERG_USER_UID:$GOTENBERG_USER_GID" build/chromium-hyphen-data /opt/gotenberg/chromium-hyphen-data
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
ENV CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
USER gotenberg
WORKDIR /home/gotenberg
@@ -275,235 +226,4 @@ WORKDIR /home/gotenberg
EXPOSE 3000
ENTRYPOINT [ "/usr/bin/tini", "--" ]
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
CMD [ "gotenberg" ]

View 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
View 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

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -25,7 +25,7 @@ const banner = `
\___/\___/\__/\__/_//_/_.__/\__/_/ \_, /
/___/
A Docker-based API for converting documents to PDF.
A containerized API for seamless PDF conversion.
Version: %s
-------------------------------------------------------
`
@@ -43,24 +43,10 @@ func Run() {
fs.Bool("gotenberg-hide-banner", false, "Hide the banner")
fs.Duration("gotenberg-graceful-shutdown-duration", time.Duration(30)*time.Second, "Set the graceful shutdown duration")
fs.Bool("gotenberg-build-debug-data", true, "Set if build data is needed")
// Logging & telemetry flags.
fs.String("log-level", gotenberg.InfoLoggingLevel, "Set the log level")
fs.String("log-fields-prefix", "", "Prepend a specified prefix to each log field key")
fs.String("log-std-format", gotenberg.AutoLoggingFormat, "Set the log format for standard output")
fs.Bool("log-std-enable-gcp-fields", false, "Use GCP-compatible field names in log output")
// Deprecated logging flags.
fs.String("log-format", gotenberg.AutoLoggingFormat, "Set the log format")
fs.Bool("log-enable-gcp-fields", false, "Use GCP-compatible field names")
if err := errors.Join(
fs.MarkDeprecated("log-format", "use --log-std-format instead"),
fs.MarkDeprecated("log-enable-gcp-fields", "use --log-std-enable-gcp-fields instead"),
); err != nil {
fmt.Printf("[FATAL] mark deprecated flags: %s\n", err)
os.Exit(1)
}
fs.String("log-level", gotenberg.InfoLoggingLevel, fmt.Sprintf("Choose the level of logging detail. Options include %s, %s, %s, or %s", gotenberg.ErrorLoggingLevel, gotenberg.WarnLoggingLevel, gotenberg.InfoLoggingLevel, gotenberg.DebugLoggingLevel))
fs.String("log-fields-prefix", "", "Prepend a specified prefix to each field in the logs")
fs.String("log-std-format", gotenberg.AutoLoggingFormat, fmt.Sprintf("Specify the format of standard logging. Options include %s, %s, or %s", gotenberg.AutoLoggingFormat, gotenberg.JsonLoggingFormat, gotenberg.TextLoggingFormat))
fs.Bool("log-std-enable-gcp-fields", false, "Enable Google Cloud Platform fields for standard logging - namely: time, message, severity")
descriptors := gotenberg.GetModuleDescriptors()
var modsInfo strings.Builder
@@ -94,11 +80,10 @@ func Run() {
fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err)
os.Exit(1)
}
f.Changed = true
return
}
err = fs.Set(f.Name, val)
err = f.Value.Set(val)
if err != nil {
fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err)
os.Exit(1)
@@ -110,43 +95,43 @@ func Run() {
hideBanner := parsedFlags.MustBool("gotenberg-hide-banner")
gracefulShutdownDuration := parsedFlags.MustDuration("gotenberg-graceful-shutdown-duration")
// Initialize telemetry (logging + OTEL).
serviceName := os.Getenv("OTEL_SERVICE_NAME")
if serviceName == "" {
serviceName = "gotenberg"
}
telemetryCfg := gotenberg.TelemetryConfig{
ServiceName: serviceName,
ServiceVersion: Version,
LogLevel: parsedFlags.MustDeprecatedString("log-format", "log-std-format"),
telemetryConfig := gotenberg.TelemetryConfig{
// OpenTelemetry.
ServiceName: serviceName,
ServiceVersion: Version,
// Logging.
LogLevel: parsedFlags.MustString("log-level"),
LogFieldsPrefix: parsedFlags.MustString("log-fields-prefix"),
LogStdFormat: parsedFlags.MustDeprecatedString("log-format", "log-std-format"),
LogStdEnableGcpFields: parsedFlags.MustDeprecatedBool("log-enable-gcp-fields", "log-std-enable-gcp-fields"),
}
// LogLevel uses its own flag, not the format flag.
telemetryCfg.LogLevel = parsedFlags.MustString("log-level")
err = telemetryCfg.Validate()
if err != nil {
fmt.Printf("[FATAL] invalid telemetry config: %s\n", err)
os.Exit(1)
}
shutdownTelemetry, err := gotenberg.StartTelemetry(telemetryCfg)
if err != nil {
fmt.Printf("[FATAL] start telemetry: %s\n", err)
os.Exit(1)
}
if !hideBanner {
fmt.Printf(banner, Version)
}
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)
// Start application modules.
apps, err := ctx.Modules(new(gotenberg.App))
if err != nil {
fmt.Printf("[FATAL] %s\n", err)
@@ -238,18 +223,16 @@ func Run() {
err = eg.Wait()
if err != nil {
cancel()
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)
if err != nil {
cancel()
fmt.Printf("[FATAL] %v\n", err)
os.Exit(1) //nolint:gocritic // defers are already called explicitly above
fmt.Printf("[FATAL] telemetry: %v\n", err)
os.Exit(1)
}
fmt.Printf("[SYSTEM] telemetry: shutdown success\n")
os.Exit(0)
}

View File

@@ -33,13 +33,12 @@ services:
- "--api-disable-download-from=${API_DISABLE_DOWNLOAD_FROM}"
- "--api-disable-health-check-route-telemetry=${API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY}"
- "--api-disable-root-route-telemetry=${API_DISABLE_ROOT_ROUTE_TELEMETRY}"
- "--api-disable-debug-route-telemetry=${API_DISABLE_DEBUG_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}"
- "--chromium-restart-after=${CHROMIUM_RESTART_AFTER}"
- "--chromium-auto-start=${CHROMIUM_AUTO_START}"
- "--chromium-max-queue-size=${CHROMIUM_MAX_QUEUE_SIZE}"
- "--chromium-idle-shutdown-timeout=${CHROMIUM_IDLE_SHUTDOWN_TIMEOUT}"
- "--chromium-max-concurrency=${CHROMIUM_MAX_CONCURRENCY}"
- "--chromium-start-timeout=${CHROMIUM_START_TIMEOUT}"
- "--chromium-allow-insecure-localhost=${CHROMIUM_ALLOW_INSECURE_LOCALHOST}"
@@ -56,11 +55,8 @@ services:
- "--chromium-disable-routes=${CHROMIUM_DISABLE_ROUTES}"
- "--libreoffice-restart-after=${LIBREOFFICE_RESTART_AFTER}"
- "--libreoffice-max-queue-size=${LIBREOFFICE_MAX_QUEUE_SIZE}"
- "--libreoffice-idle-shutdown-timeout=${LIBREOFFICE_IDLE_SHUTDOWN_TIMEOUT}"
- "--libreoffice-auto-start=${LIBREOFFICE_AUTO_START}"
- "--libreoffice-start-timeout=${LIBREOFFICE_START_TIMEOUT}"
- "--libreoffice-allow-list=${LIBREOFFICE_ALLOW_LIST}"
- "--libreoffice-deny-list=${LIBREOFFICE_DENY_LIST}"
- "--libreoffice-disable-routes=${LIBREOFFICE_DISABLE_ROUTES}"
- "--log-level=${LOG_LEVEL}"
- "--log-fields-prefix=${LOG_FIELDS_PREFIX}"
@@ -72,23 +68,14 @@ services:
- "--pdfengines-convert-engines=${PDFENGINES_CONVERT_ENGINES}"
- "--pdfengines-read-metadata-engines=${PDFENGINES_READ_METADATA_ENGINES}"
- "--pdfengines-write-metadata-engines=${PDFENGINES_WRITE_METADATA_ENGINES}"
- "--pdfengines-read-bookmarks-engines=${PDFENGINES_READ_BOOKMARKS_ENGINES}"
- "--pdfengines-write-bookmarks-engines=${PDFENGINES_WRITE_BOOKMARKS_ENGINES}"
- "--pdfengines-watermark-engines=${PDFENGINES_WATERMARK_ENGINES}"
- "--pdfengines-stamp-engines=${PDFENGINES_STAMP_ENGINES}"
- "--pdfengines-encrypt-engines=${PDFENGINES_ENCRYPT_ENGINES}"
- "--pdfengines-rotate-engines=${PDFENGINES_ROTATE_ENGINES}"
- "--pdfengines-embed-engines=${PDFENGINES_EMBED_ENGINES}"
- "--pdfengines-embed-metadata-engines=${PDFENGINES_EMBED_METADATA_ENGINES}"
- "--pdfengines-disable-routes=${PDFENGINES_DISABLE_ROUTES}"
- "--prometheus-namespace=${PROMETHEUS_NAMESPACE}"
- "--prometheus-collect-interval=${PROMETHEUS_COLLECT_INTERVAL}"
- "--prometheus-disable-route-telemetry=${PROMETHEUS_DISABLE_ROUTE_TELEMETRY}"
- "--prometheus-disable-collect=${PROMETHEUS_DISABLE_COLLECT}"
- "--prometheus-metrics-path=${PROMETHEUS_METRICS_PATH}"
- "--pdfengines-add-attachments-engines=${PDFENGINES_ADD_ATTACHMENTS_ENGINES}"
- "--webhook-enable-sync-mode=${WEBHOOK_ENABLE_SYNC_MODE}"
- "--webhook-allow-list=${WEBHOOK_ALLOW_LIST}"
- "--webhook-deny-list=${WEBHOOK_DENY_LIST}"
- "--webhook-error-allow-list=${WEBHOOK_ERROR_ALLOW_LIST}"
- "--webhook-error-deny-list=${WEBHOOK_ERROR_DENY_LIST}"
- "--webhook-max-retry=${WEBHOOK_MAX_RETRY}"
- "--webhook-retry-min-wait=${WEBHOOK_RETRY_MIN_WAIT}"
- "--webhook-retry-max-wait=${WEBHOOK_RETRY_MAX_WAIT}"
@@ -103,20 +90,14 @@ services:
ports:
- "4317:4317" # OTLP gRPC receiver
depends_on:
openobserve:
condition: service_started
restart: on-failure
- openobserve
openobserve:
image: public.ecr.aws/zinclabs/openobserve:latest
restart: always
ports:
- "5080:5080"
- "5081:5081"
- "5081:5081" # OTLP gRPC ingestion
environment:
ZO_ROOT_USER_EMAIL: telemetry@gotenberg.dev
ZO_ROOT_USER_PASSWORD: telemetry
networks:
default:
enable_ipv6: false

105
go.mod
View File

@@ -1,39 +1,41 @@
module github.com/gotenberg/gotenberg/v8
go 1.26.2
go 1.26.0
require (
github.com/alexliesenfeld/health v0.8.1
github.com/barasher/go-exiftool v1.10.0
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
github.com/chromedp/chromedp v0.14.2
github.com/cucumber/godog v0.15.1
github.com/dlclark/regexp2 v1.12.0
github.com/gomarkdown/markdown v0.0.0-20260412113850-134a5b2cce7f
github.com/dlclark/regexp2 v1.11.5
github.com/docker/docker v28.5.2+incompatible
github.com/docker/go-connections v0.6.0
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
github.com/google/uuid v1.6.0
github.com/hashicorp/go-retryablehttp v0.7.8
github.com/labstack/echo/v4 v4.15.1
github.com/labstack/gommon v0.5.0
github.com/labstack/gommon v0.4.2
github.com/mholt/archives v0.1.5
github.com/microcosm-cc/bluemonday v1.0.27
github.com/moby/moby/api v1.54.1
github.com/prometheus/client_golang v1.23.2
github.com/shirou/gopsutil/v4 v4.26.3
github.com/shirou/gopsutil/v4 v4.26.1
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.42.0
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/log v0.19.0
go.opentelemetry.io/otel/metric v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/sdk/log v0.19.0
go.opentelemetry.io/otel/sdk/metric v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
golang.org/x/net v0.53.0
golang.org/x/sync v0.20.0
golang.org/x/term v0.42.0
golang.org/x/text v0.36.0
github.com/testcontainers/testcontainers-go v0.40.0
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0
go.opentelemetry.io/otel v1.42.0
go.opentelemetry.io/otel/log v0.18.0
go.opentelemetry.io/otel/metric v1.42.0
go.opentelemetry.io/otel/sdk v1.42.0
go.opentelemetry.io/otel/sdk/log v0.18.0
go.opentelemetry.io/otel/sdk/metric v1.42.0
go.opentelemetry.io/otel/trace v1.42.0
go.uber.org/multierr v1.11.0
golang.org/x/net v0.51.0
golang.org/x/sync v0.19.0
golang.org/x/term v0.40.0
golang.org/x/text v0.34.0
)
require (
@@ -41,7 +43,7 @@ require (
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/STARRY-S/zip v0.2.3 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bodgit/plumbing v1.3.0 // indirect
@@ -60,7 +62,6 @@ require (
github.com/cucumber/messages/go/v21 v21.0.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
@@ -80,29 +81,31 @@ require (
github.com/hashicorp/go-memdb v1.3.5 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/compress v1.18.4 // 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/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/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/go-archive v0.2.0 // indirect
github.com/moby/moby/client v0.4.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.1.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nwaples/rardecode/v2 v2.2.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pierrec/lz4/v4 v4.1.26 // indirect
github.com/pierrec/lz4/v4 v4.1.25 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
@@ -117,28 +120,28 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect
google.golang.org/grpc v1.80.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/grpc v1.79.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

219
go.sum
View File

@@ -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/alexliesenfeld/health v0.8.1 h1:wdE3vt+cbJotiR8DGDBZPKHDFoJbAoWEfQTcqrmedUg=
github.com/alexliesenfeld/health v0.8.1/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
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/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEXNMpIhs=
github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
@@ -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/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
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/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0=
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/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/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/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/gomarkdown/markdown v0.0.0-20260412113850-134a5b2cce7f h1:C5vKBogs/Qf5ID8F8XuRO8SFL+5SH7JMJrAfdLAZ2iA=
github.com/gomarkdown/markdown v0.0.0-20260412113850-134a5b2cce7f/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
@@ -130,8 +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/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
@@ -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/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs=
github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0=
github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
github.com/minio/minlz v1.1.0 h1:rUOGu3EP4EqJC5k3qCsIwEnZiJULKqtRyDdqbhlvMmQ=
github.com/minio/minlz v1.1.0/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A=
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/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
@@ -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/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nwaples/rardecode/v2 v2.2.2 h1:/5oL8dzYivRM/tqX9VcTSWfbpwcbwKG1QtSJr3b3KcU=
@@ -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/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
@@ -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/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo=
github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
@@ -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.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.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
@@ -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=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0 h1:hhPGP3zvvy1xWT9RTy970wlniSxFttBIsAK1gvMguJM=
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0/go.mod h1:twJF7inoMza6kxMcF8JOdL3mPmtOZu7GEr34CUNE6Dg=
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 h1:w3zlHYETbDwXyWHZlyyR58ZC39XGi8rAhkBgUgJ9d5w=
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0/go.mod h1:GR/mClR2nn7vE8RLwxKjoBNg+QtgdDhRzxVa93koy5o=
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0 h1:0D3GFvELGIwQGfC6agLsbrEYSGWZTRTxIXxcQUqrOuk=
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0/go.mod h1:DM2NV7Zb8CcGeVPt6glouY0FAiwZQ/iqgcWExhgWeN8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 h1:Dn8rkudDzY6KV9dr/D/bTUuWgqDf9xe0rr4G2elrn0Y=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0/go.mod h1:gMk9F0xDgyN9M/3Ed5Y1wKcx/9mlU91NXY2SNq7RQuU=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
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.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0 h1:GJkybS+crDMdExT/BUNCEgfrmfboztcS6PhvSo88HKM=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0/go.mod h1:NuAyxRYIG2lKX3YQkB+83StTxM7s52PUUkRRiC0wnYI=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4=
go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko=
go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg=
go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk=
go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0 h1:yOYhGNPZseueTTvWp5iBD3/CthrmvayUXYEX862dDi4=
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0/go.mod h1:CvaNVqIfcybc+7xqZNubbE+26K6P7AKZF/l0lE2kdCk=
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 h1:dkBzNEAIKADEaFnuESzcXvpd09vxvDZsOjx11gjUqLk=
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0/go.mod h1:Z5RIwRkZgauOIfnG5IpidvLpERjhTninpP1dTG2jTl4=
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 h1:4fnRcNpc6YFtG3zsFw9achKn3XgmxPxuMuqIL5rE8e8=
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0/go.mod h1:qTvIHMFKoxW7HXg02gm6/Wofhq5p3Ib/A/NNt1EoBSQ=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 h1:deI9UQMoGFgrg5iLPgzueqFPHevDl+28YKfSpPTI6rY=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0/go.mod h1:PFx9NgpNUKXdf7J4Q3agRxMs3Y07QhTCVipKmLsMKnU=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 h1:icqq3Z34UrEFk2u+HMhTtRsvo7Ues+eiJVjaJt62njs=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0/go.mod h1:W2m8P+d5Wn5kipj4/xmbt9uMqezEKfBjzVJadfABSBE=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 h1:H7O6RlGOMTizyl3R08Kn5pdM06bnH8oscSj7o11tmLA=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0/go.mod h1:mBFWu/WOVDkWWsR7Tx7h6EpQB8wsv7P0Yrh0Pb7othc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc=
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs=
go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 h1:KJVjPD3rcPb98rIs3HznyJlrfx9ge5oJvxxlGR+P/7s=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0/go.mod h1:K3kRa2ckmHWQaTWQdPRHc7qGXASuVuoEQXzrvlA98Ws=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs=
go.opentelemetry.io/otel/log v0.18.0 h1:XgeQIIBjZZrliksMEbcwMZefoOSMI1hdjiLEiiB0bAg=
go.opentelemetry.io/otel/log v0.18.0/go.mod h1:KEV1kad0NofR3ycsiDH4Yjcoj0+8206I6Ox2QYFSNgI=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
go.opentelemetry.io/otel/sdk/log v0.18.0 h1:n8OyZr7t7otkeTnPTbDNom6rW16TBYGtvyy2Gk6buQw=
go.opentelemetry.io/otel/sdk/log v0.18.0/go.mod h1:C0+wxkTwKpOCZLrlJ3pewPiiQwpzycPI/u6W0Z9fuYk=
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0 h1:l3mYuPsuBx6UKE47BVcPrZoZ0q/KER57vbj2qkgDLXA=
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0/go.mod h1:7cHtiVJpZebB3wybTa4NG+FUo5NPe3PROz1FqB0+qdw=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d h1:/aDRtSZJjyLQzm75d+a1wOJaqyKBMvIAfeQmoa3ORiI=
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:etfGUgejTiadZAUaEP14NP97xi1RGeawqkjDARA/UOs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4=
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -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=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=

22
package-lock.json generated
View File

@@ -5,7 +5,7 @@
"packages": {
"": {
"devDependencies": {
"prettier": "3.8.3",
"prettier": "3.8.1",
"prettier-plugin-gherkin": "^3.1.3",
"prettier-plugin-sh": "^0.18.0"
}
@@ -34,9 +34,9 @@
}
},
"node_modules/@reteps/dockerfmt": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.5.2.tgz",
"integrity": "sha512-Hbr7yen4fP5TxGM54ucXa4o5NwWXatJ6Bd9I8gp0PValYbI4Rug2Gu+rVv7K7o/efQc3F5ctqWJz47rYaa8zBw==",
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.3.6.tgz",
"integrity": "sha512-Tb5wIMvBf/nLejTQ61krK644/CEMB/cpiaIFXqGApfGqO3GwcR3qnI0DbmkFVCl2OyEp8LnLX3EkucoL0+tbFg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -58,9 +58,9 @@
"license": "MIT"
},
"node_modules/prettier": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"bin": {
@@ -86,13 +86,13 @@
}
},
"node_modules/prettier-plugin-sh": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.18.1.tgz",
"integrity": "sha512-uZmU22wBMevjh3rmCatNQqiEer2+5KLa0xYCBX6zQQUQkcNzVL+s6FbPKK6ZSUNUbQk6jMAcQHrYPvuL2W6ihQ==",
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.18.0.tgz",
"integrity": "sha512-cW1XL27FOJQ/qGHOW6IHwdCiNWQsAgK+feA8V6+xUTaH0cD3Mh+tFAtBvEEWvuY6hTDzRV943Fzeii+qMOh7nQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@reteps/dockerfmt": "^0.5.1",
"@reteps/dockerfmt": "^0.3.6",
"sh-syntax": "^0.5.8"
},
"engines": {

View File

@@ -1,6 +1,6 @@
{
"devDependencies": {
"prettier": "3.8.3",
"prettier": "3.8.1",
"prettier-plugin-gherkin": "^3.1.3",
"prettier-plugin-sh": "^0.18.0"
}

View File

@@ -55,13 +55,6 @@ func CommandContext(ctx context.Context, logger *slog.Logger, binPath string, ar
}, nil
}
// SetEnv replaces the environment variables passed to the underlying
// process. When SetEnv is not called, the process inherits the parent's
// environment.
func (cmd *Cmd) SetEnv(env []string) {
cmd.process.Env = env
}
// Start starts the command but does not wait for its completion.
func (cmd *Cmd) Start() error {
err := cmd.pipeOutput()
@@ -69,7 +62,7 @@ func (cmd *Cmd) Start() error {
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()
if err != nil {
@@ -116,7 +109,7 @@ func (cmd *Cmd) Exec() (int, error) {
case err = <-errChan:
errProc := cmd.Kill()
if errProc != nil {
cmd.logger.ErrorContext(context.Background(), errProc.Error())
cmd.logger.ErrorContext(cmd.ctx, errProc.Error())
}
if err == nil {
@@ -131,7 +124,7 @@ func (cmd *Cmd) Exec() (int, error) {
case <-cmd.ctx.Done():
errProc := cmd.Kill()
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())
@@ -141,7 +134,7 @@ func (cmd *Cmd) Exec() (int, error) {
// pipeOutput creates logs entries according to the process stdout and stderr.
// It does nothing if the logging level is not debug.
func (cmd *Cmd) pipeOutput() error {
if !cmd.logger.Enabled(context.Background(), slog.LevelDebug) {
if !cmd.logger.Enabled(cmd.ctx, slog.LevelDebug) {
return nil
}
@@ -162,7 +155,7 @@ func (cmd *Cmd) pipeOutput() error {
defer func(reader io.ReadCloser) {
err := reader.Close()
if err != nil && !strings.Contains(err.Error(), "file already closed") {
logger.ErrorContext(context.Background(), fmt.Sprintf("close reader: %s", err))
logger.ErrorContext(cmd.ctx, fmt.Sprintf("close reader: %s", err))
}
}(reader)
@@ -170,14 +163,14 @@ func (cmd *Cmd) pipeOutput() error {
line, _, err := r.ReadLine()
if err != nil {
if err != io.EOF && !strings.Contains(err.Error(), "file already closed") {
logger.ErrorContext(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
}
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)
if err == nil {
cmd.logger.DebugContext(context.Background(), "unix process killed")
cmd.logger.DebugContext(cmd.ctx, "unix process killed")
return nil
}
// If the process does not exist anymore, the error is irrelevant.
if strings.Contains(err.Error(), "no such process") {
cmd.logger.DebugContext(context.Background(), "unix process already killed")
cmd.logger.DebugContext(cmd.ctx, "unix process already killed")
return nil
}

View File

@@ -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
// License 2.0.
// caddyserver/caddy, licensed under the Apache License 2.0, has significantly
// inspired this module system.
//
// See https://caddyserver.com/.
// More details are available on https://caddyserver.com/.
package gotenberg

View File

@@ -12,57 +12,40 @@ import (
// ErrFiltered happens if a value is filtered by the [FilterDeadline] function.
var ErrFiltered = errors.New("value filtered")
// FilterDeadline checks if the given value is allowed and not denied according
// to regex patterns. The allowed list uses OR semantics (value must match at
// least one pattern). The denied list uses OR semantics (value is denied if it
// matches any pattern). It returns a [context.DeadlineExceeded] if it takes
// too long to process.
func FilterDeadline(allowed, denied []*regexp2.Regexp, s string, deadline time.Time) error {
if len(allowed) > 0 {
matched := false
// FilterDeadline checks if given value is allowed and not denied according to
// regex patterns. It returns a [context.DeadlineExceeded] if it takes too long
// to process.
func FilterDeadline(allowed, denied *regexp2.Regexp, s string, deadline time.Time) error {
// FIXME: not ideal to compile everytime, but is there another way to create a clone?
if allowed.String() != "" {
allow := regexp2.MustCompile(allowed.String(), 0)
allow.MatchTimeout = time.Until(deadline)
for _, pattern := range allowed {
// FIXME: not ideal to compile everytime, but is there another way to create a clone?
clone := regexp2.MustCompile(pattern.String(), 0)
clone.MatchTimeout = time.Until(deadline)
ok, err := clone.MatchString(s)
if err != nil {
if time.Now().After(deadline) {
return context.DeadlineExceeded
}
return fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), s, err)
}
if ok {
matched = true
break
ok, err := allow.MatchString(s)
if err != nil {
if time.Now().After(deadline) {
return context.DeadlineExceeded
}
return fmt.Errorf("'%s' cannot handle '%s': %w", allow.String(), s, err)
}
if !matched {
return fmt.Errorf("'%s' does not match any expression from the allowed list: %w", s, ErrFiltered)
if !ok {
return fmt.Errorf("'%s' does not match the expression from the allowed list: %w", s, ErrFiltered)
}
}
if len(denied) > 0 {
for _, pattern := range denied {
clone := regexp2.MustCompile(pattern.String(), 0)
clone.MatchTimeout = time.Until(deadline)
if denied.String() != "" {
deny := regexp2.MustCompile(denied.String(), 0)
deny.MatchTimeout = time.Until(deadline)
ok, err := clone.MatchString(s)
if err != nil {
if time.Now().After(deadline) {
return context.DeadlineExceeded
}
return fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), s, err)
}
if ok {
return fmt.Errorf("'%s' matches the expression from the denied list: %w", s, ErrFiltered)
ok, err := deny.MatchString(s)
if err != nil {
if time.Now().After(deadline) {
return context.DeadlineExceeded
}
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)
}
}

View File

@@ -12,8 +12,8 @@ import (
func TestFilterDeadline(t *testing.T) {
for _, tc := range []struct {
scenario string
allowed []*regexp2.Regexp
denied []*regexp2.Regexp
allowed *regexp2.Regexp
denied *regexp2.Regexp
s string
deadline time.Time
expectError bool
@@ -21,17 +21,17 @@ func TestFilterDeadline(t *testing.T) {
}{
{
scenario: "DeadlineExceeded (allowed)",
allowed: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)},
denied: nil,
allowed: regexp2.MustCompile("foo", 0),
denied: regexp2.MustCompile("", 0),
s: "foo",
deadline: time.Now().Add(time.Duration(-1) * time.Hour),
expectError: true,
expectedError: context.DeadlineExceeded,
},
{
scenario: "ErrFiltered (allowed, no match)",
allowed: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)},
denied: nil,
scenario: "ErrFiltered (allowed)",
allowed: regexp2.MustCompile("foo", 0),
denied: regexp2.MustCompile("", 0),
s: "bar",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: true,
@@ -39,8 +39,8 @@ func TestFilterDeadline(t *testing.T) {
},
{
scenario: "DeadlineExceeded (denied)",
allowed: nil,
denied: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)},
allowed: regexp2.MustCompile("", 0),
denied: regexp2.MustCompile("foo", 0),
s: "foo",
deadline: time.Now().Add(time.Duration(-1) * time.Hour),
expectError: true,
@@ -48,55 +48,21 @@ func TestFilterDeadline(t *testing.T) {
},
{
scenario: "ErrFiltered (denied)",
allowed: nil,
denied: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)},
allowed: regexp2.MustCompile("", 0),
denied: regexp2.MustCompile("foo", 0),
s: "foo",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: true,
expectedError: ErrFiltered,
},
{
scenario: "success (empty lists)",
allowed: nil,
denied: nil,
scenario: "success",
allowed: regexp2.MustCompile("", 0),
denied: regexp2.MustCompile("", 0),
s: "foo",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: false,
},
{
scenario: "multi-pattern allow list, second matches",
allowed: []*regexp2.Regexp{regexp2.MustCompile("^https://", 0), regexp2.MustCompile("^file:///tmp/", 0)},
denied: nil,
s: "file:///tmp/abc/index.html",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: false,
},
{
scenario: "multi-pattern allow list, none matches",
allowed: []*regexp2.Regexp{regexp2.MustCompile("^https://", 0), regexp2.MustCompile("^ftp://", 0)},
denied: nil,
s: "file:///tmp/abc/index.html",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: true,
expectedError: ErrFiltered,
},
{
scenario: "multi-pattern deny list, second matches",
allowed: nil,
denied: []*regexp2.Regexp{regexp2.MustCompile("^ftp://", 0), regexp2.MustCompile("^file:.*", 0)},
s: "file:///etc/passwd",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: true,
expectedError: ErrFiltered,
},
{
scenario: "https URL passes deny list targeting file://",
allowed: nil,
denied: []*regexp2.Regexp{regexp2.MustCompile("^file:.*", 0)},
s: "https://example.com",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := FilterDeadline(tc.allowed, tc.denied, tc.s, tc.deadline)

View File

@@ -222,32 +222,3 @@ func (f *ParsedFlags) MustDeprecatedRegexp(deprecated string, newName string) *r
return f.MustRegexp(newName)
}
// MustRegexpSlice returns a slice of compiled regular expressions from a
// string-slice flag given by name. Empty strings are skipped.
// It panics if an error occurs.
func (f *ParsedFlags) MustRegexpSlice(name string) []*regexp2.Regexp {
vals := f.MustStringSlice(name)
var regexps []*regexp2.Regexp
for _, val := range vals {
if val == "" {
continue
}
regexps = append(regexps, regexp2.MustCompile(val, 0))
}
return regexps
}
// MustDeprecatedRegexpSlice returns the slice of compiled regular expressions
// of a deprecated flag if it was explicitly set or the slice of the new flag.
// It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedRegexpSlice(deprecated string, newName string) []*regexp2.Regexp {
if f.Changed(deprecated) {
return f.MustRegexpSlice(deprecated)
}
return f.MustRegexpSlice(newName)
}

View File

@@ -6,7 +6,6 @@ import (
"testing"
"time"
"github.com/dlclark/regexp2"
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.
}

View File

@@ -38,7 +38,7 @@ func GarbageCollect(ctx context.Context, logger *slog.Logger, rootPath string, i
for _, substr := range includeSubstr {
if (strings.Contains(info.Name(), substr) || path == substr) && info.ModTime().Before(expirationTime) {
err := os.RemoveAll(path) //nolint:gosec // G122: rootPath is a trusted internal working directory
err := os.RemoveAll(path)
if err != nil {
return fmt.Errorf("garbage collect '%s': %w", path, err)
}

View File

@@ -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 {
t.Fatalf("expected no error but got: %v", err)

View File

@@ -71,17 +71,16 @@ func NewStdHandler(level slog.Level, format string, fieldsPrefix string, enableG
// Configure level encoding based on format and GCP settings.
if a.Key == slog.LevelKey {
l := a.Value.Any().(slog.Level)
switch {
case format == textLoggingFormat && isTerminal:
if format == textLoggingFormat && isTerminal {
if enableGcpFields {
a.Value = slog.StringValue(gcpSeverityColorEncoder(l))
} else {
a.Value = slog.StringValue(levelToColor(l).Add(l.String()))
}
case enableGcpFields && format != textLoggingFormat:
} else if enableGcpFields && format != textLoggingFormat {
a.Key = "severity"
a.Value = slog.StringValue(gcpSeverity(l))
default:
} else {
a.Value = slog.StringValue(strings.ToLower(l.String()))
}
}

View File

@@ -1,7 +1,8 @@
// Package otel gathers initialization utilities for OpenTelemetry
// 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

View File

@@ -1 +0,0 @@
package gotenberg

View File

@@ -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)
}

View File

@@ -45,21 +45,14 @@ func (mod *DebuggableMock) Debug() map[string]any {
//
//nolint:dupl
type PdfEngineMock struct {
MergeMock func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath string) error
ConvertMock func(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
ReadBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error)
EncryptMock func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
EmbedFilesMock func(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
EmbedFilesMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error
WriteBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
WatermarkMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
StampMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
RotateMock func(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
MergeMock func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath string) error
ConvertMock func(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
EncryptMock func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
AddAttachmentsMock func(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
}
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)
}
func (engine *PdfEngineMock) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
return engine.PageCountMock(ctx, logger, inputPath)
}
func (engine *PdfEngineMock) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
return engine.WriteMetadataMock(ctx, logger, metadata, inputPath)
}
func (engine *PdfEngineMock) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error) {
return engine.ReadBookmarksMock(ctx, logger, inputPath)
}
func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
return engine.EncryptMock(ctx, logger, inputPath, userPassword, ownerPassword)
}
func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
return engine.EmbedFilesMock(ctx, logger, filePaths, inputPath)
}
func (engine *PdfEngineMock) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
return engine.EmbedFilesMetadataMock(ctx, logger, metadata, inputPath)
}
func (engine *PdfEngineMock) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error {
return engine.WriteBookmarksMock(ctx, logger, inputPath, bookmarks)
}
func (engine *PdfEngineMock) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error {
return engine.WatermarkMock(ctx, logger, inputPath, stamp)
}
func (engine *PdfEngineMock) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error {
return engine.StampMock(ctx, logger, inputPath, stamp)
}
func (engine *PdfEngineMock) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
return engine.RotateMock(ctx, logger, inputPath, angle, pages)
func (engine *PdfEngineMock) AddAttachments(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
return engine.AddAttachmentsMock(ctx, logger, filePaths, inputPath)
}
// PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface.
@@ -157,8 +122,8 @@ type ProcessSupervisorMock struct {
HealthyMock func() bool
RunMock func(ctx context.Context, logger *slog.Logger, task func() error) error
ReqQueueSizeMock func() int64
RestartsCountMock func() int64
ActiveTasksCountMock func() int64
RestartsCountMock func() int64
}
func (s *ProcessSupervisorMock) Launch() error {
@@ -181,21 +146,12 @@ func (s *ProcessSupervisorMock) ReqQueueSize() int64 {
return s.ReqQueueSizeMock()
}
func (s *ProcessSupervisorMock) RestartsCount() int64 {
return s.RestartsCountMock()
}
func (s *ProcessSupervisorMock) ActiveTasksCount() int64 {
return s.ActiveTasksCountMock()
}
// MetricsProviderMock is a mock for the [MetricsProvider] interface.
type MetricsProviderMock struct {
MetricsMock func() ([]Metric, error)
}
func (provider *MetricsProviderMock) Metrics() ([]Metric, error) {
return provider.MetricsMock()
func (s *ProcessSupervisorMock) RestartsCount() int64 {
return s.RestartsCountMock()
}
// MkdirAllMock is a mock for the [MkdirAll] interface.
@@ -224,7 +180,6 @@ var (
_ PdfEngineProvider = (*PdfEngineProviderMock)(nil)
_ Process = (*ProcessMock)(nil)
_ ProcessSupervisor = (*ProcessSupervisorMock)(nil)
_ MetricsProvider = (*MetricsProviderMock)(nil)
_ MkdirAll = (*MkdirAllMock)(nil)
_ PathRename = (*PathRenameMock)(nil)
)

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -27,14 +27,6 @@ var (
// ErrPdfEncryptionNotSupported is returned when encryption
// is not supported by the PDF engine.
ErrPdfEncryptionNotSupported = errors.New("encryption not supported")
// ErrPdfStampSourceNotSupported is returned when a stamp source type
// is not supported by the PDF engine.
ErrPdfStampSourceNotSupported = errors.New("stamp source not supported")
// ErrPdfRotateAngleNotSupported is returned when the rotation angle is
// not supported.
ErrPdfRotateAngleNotSupported = errors.New("rotation angle not supported")
)
// PdfEngineInvalidArgsError represents an error returned by a PDF engine when
@@ -56,33 +48,6 @@ func NewPdfEngineInvalidArgs(engine, msg string) error {
return &PdfEngineInvalidArgsError{engine, msg}
}
const (
// StampSourceText represents a text-based stamp source.
StampSourceText string = "text"
// StampSourceImage represents an image-based stamp source.
StampSourceImage string = "image"
// StampSourcePDF represents a PDF-based stamp source.
StampSourcePDF string = "pdf"
)
// Stamp gathers the data required to apply a watermark or stamp to a PDF.
type Stamp struct {
// Source is one of "text", "image", or "pdf".
Source string
// Expression is the text content (for text source) or file path (for
// image/pdf source).
Expression string
// Pages is the optional page range to apply the stamp to.
Pages string
// Options holds engine-specific styling options.
Options map[string]string
}
const (
// SplitModeIntervals represents a mode where a PDF is split at specific
// intervals.
@@ -143,14 +108,6 @@ type PdfFormats struct {
PdfUa bool
}
// Bookmark represents a node in the PDF document's outline
// (table of contents).
type Bookmark struct {
Title string `json:"title"`
Page int `json:"page"`
Children []Bookmark `json:"children,omitempty"`
}
// PdfEngine provides an interface for operations on PDFs. Implementations
// can use various tools like PDFtk, or implement functionality directly in
// Go.
@@ -177,45 +134,18 @@ type PdfEngine interface {
// ReadMetadata extracts the metadata of a given PDF file.
ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
// PageCount returns the number of pages in a PDF file.
PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
// WriteMetadata writes the metadata into a given PDF file.
WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
// ReadBookmarks reads the document outline (bookmarks) of a PDF file.
ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error)
// WriteBookmarks adds a document outline (bookmarks) to a PDF file.
// The bookmarks parameter represents the hierarchical tree of the outline.
WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
// Encrypt adds password protection to a PDF file.
// The userPassword is required to open the document.
// The ownerPassword provides full access to the document.
// If the ownerPassword is empty, it defaults to the userPassword.
Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
// without modifying the main PDF content.
// TODO: attachments instead? Rename the route?
EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
// EmbedFilesMetadata sets metadata (such as MIME type and AFRelationship)
// on already-embedded files in a PDF. The metadata map is keyed by
// filename, with each value being a map of property names to values
// (e.g., "mimeType" and "relationship").
EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error
// Watermark applies a watermark (behind page content) to a PDF file.
Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
// Stamp applies a stamp (on top of page content) to a PDF file.
Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
// Rotate rotates pages of a PDF file by the given angle (90, 180, 270).
// If pages is empty, all pages are rotated.
Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
// AddAttachments adds attachments into a PDF. All files are attached as
// file attachments without modifying the main PDF content.
AddAttachments(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
}
// PdfEngineProvider offers an interface to instantiate a [PdfEngine].

View File

@@ -1,5 +1,7 @@
// Package semconv contains utilities not yet exposed in the OpenTelemetry Go
// SDK. Remove this package once an official API exists.
// Package semconv is a copy/paste of utilities that are currently not exposed
// 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

View File

@@ -7,7 +7,6 @@ import (
"log/slog"
"sync"
"sync/atomic"
"time"
)
// 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].
//
// A non-started process is considered healthy (startup is deferred until
// the first request). Returns false if the process is currently restarting
// or is reported unhealthy by the underlying [Process].
// If the process has not been started or is restarting, it is considered
// healthy and true is returned. Otherwise, it returns the health status of
// the actual process.
Healthy() bool
// 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() int64
// RestartsCount returns the current number of restart.
RestartsCount() int64
// ActiveTasksCount returns the current number of active tasks.
ActiveTasksCount() int64
// RestartsCount returns the current number of restart.
RestartsCount() int64
}
type processSupervisor struct {
logger *slog.Logger
process Process
maxReqLimit int64
maxQueueSize int64
maxConcurrency int64
semaphore chan struct{}
firstStart atomic.Bool
// firstStartMu serializes lazy-launch attempts so concurrent callers do
// not all spawn Launch() simultaneously. Using a mutex (instead of
// sync.Once) lets a failed launch be retried by the next caller, since a
// transient failure (such as a cold-start timeout) must not poison the
// supervisor for the rest of the container's lifetime. See
// https://github.com/gotenberg/gotenberg/issues/1538.
firstStartMu sync.Mutex
reqCounter atomic.Int64
reqQueueSize atomic.Int64
restartsCounter atomic.Int64
isRestarting atomic.Bool
activeTasks atomic.Int64
restartMutex sync.Mutex
idleShutdownTimeout time.Duration
lastActivity atomic.Int64 // unix nano timestamp of last completed task
idleMu sync.Mutex // protects idleStopChan
idleStopChan chan struct{} // signal to stop the idle ticker goroutine
logger *slog.Logger
process Process
maxReqLimit int64
maxQueueSize int64
maxConcurrency int64
semaphore chan struct{}
firstStart atomic.Bool
firstStartOnce sync.Once
firstStartErr error
reqCounter atomic.Int64
reqQueueSize atomic.Int64
restartsCounter atomic.Int64
isRestarting atomic.Bool
activeTasks atomic.Int64
restartMutex sync.Mutex
}
// 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 {
maxConcurrency = 1
}
b := &processSupervisor{
logger: logger,
process: process,
semaphore: make(chan struct{}, maxConcurrency),
maxReqLimit: maxReqLimit,
maxQueueSize: maxQueueSize,
maxConcurrency: maxConcurrency,
idleShutdownTimeout: idleShutdownTimeout,
logger: logger,
process: process,
semaphore: make(chan struct{}, maxConcurrency),
maxReqLimit: maxReqLimit,
maxQueueSize: maxQueueSize,
maxConcurrency: maxConcurrency,
}
b.reqCounter.Store(0)
b.reqQueueSize.Store(0)
@@ -137,12 +126,6 @@ func (s *processSupervisor) Launch() error {
}
s.firstStart.Store(true)
if s.idleShutdownTimeout > 0 {
s.lastActivity.Store(time.Now().UnixNano())
s.startIdleTicker()
}
s.logger.DebugContext(context.Background(), "process successfully started")
return nil
@@ -150,9 +133,6 @@ func (s *processSupervisor) Launch() error {
func (s *processSupervisor) Shutdown() error {
s.logger.DebugContext(context.Background(), "shutdown process")
s.stopIdleTicker()
err := s.process.Stop(s.logger)
if err != nil {
return fmt.Errorf("shutdown process: %w", err)
@@ -168,9 +148,8 @@ func (s *processSupervisor) restart() error {
err := s.Shutdown()
if err != nil {
// Not necessarily critical — chances are the process is already stopped,
// but worth flagging in case it indicates a real issue.
s.logger.WarnContext(context.Background(), fmt.Sprintf("stop process before restart: %s", err))
// No big deal? Chances are it's already stopped.
s.logger.DebugContext(context.Background(), fmt.Sprintf("stop process before restart: %s", err))
}
err = s.Launch()
@@ -187,84 +166,117 @@ func (s *processSupervisor) restart() error {
func (s *processSupervisor) Healthy() bool {
if !s.firstStart.Load() {
// A non-started process is considered healthy: Gotenberg defers
// process startup until the first request to keep resource usage low.
// Reporting unhealthy here would cause container orchestrators to
// restart the pod before any request arrives.
// A non-started process is always healthy.
return true
}
if s.isRestarting.Load() {
// A restarting process is not yet healthy — this gives load balancers
// honest information so they can avoid routing traffic to this node.
return false
// A restarting process is always healthy.
return true
}
return s.process.Healthy(s.logger)
}
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
// originally reported in https://github.com/gotenberg/gotenberg/issues/951.
for {
current := s.reqQueueSize.Load()
if s.maxQueueSize > 0 && current >= s.maxQueueSize {
return ErrMaximumQueueSizeExceeded
}
if s.reqQueueSize.CompareAndSwap(current, current+1) {
break
}
// A user reported a potential issue:
//
// "Although the counting operation is atomic, nothing prevent 2 concurrent
// goroutines to retrieve the same 'currentQueueSize' and to compare its
// value against the max limit. Then, resulting queue size would be 1 above
// the allowed limit."
//
// However, he was unable to actually trigger this issue, even when sending
// a lot of requests.
//
// For now, the best option is to consider this issue to be unlikely to
// happen, and keep the code as it is because it is more readable this way.
//
// See https://github.com/gotenberg/gotenberg/issues/951.
currentQueueSize := s.reqQueueSize.Load()
if s.maxQueueSize > 0 && currentQueueSize >= s.maxQueueSize {
return ErrMaximumQueueSizeExceeded
}
// Decrement when Run() returns, regardless of which path is taken
// (context timeout, task completion, error, etc.). This ensures the
// request is counted as "in the queue" for the entire duration of Run(),
// preventing new requests from entering while one is being processed.
// See https://github.com/gotenberg/gotenberg/issues/1502.
defer s.reqQueueSize.Add(-1)
s.reqQueueSize.Add(1)
for {
err := func() error {
if err := s.acquireSlot(ctx, logger); err != nil {
return err
}
select {
case s.semaphore <- struct{}{}:
logger.DebugContext(ctx, "process lock acquired")
s.reqCounter.Add(1)
s.activeTasks.Add(1)
semaphoreOwned := true
defer func() {
s.activeTasks.Add(-1)
if s.idleShutdownTimeout > 0 {
s.lastActivity.Store(time.Now().UnixNano())
}
if semaphoreOwned {
logger.DebugContext(ctx, "process lock released")
// 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
}
}()
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
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) {
logger.DebugContext(ctx, "process is already restarting, trying to acquire process lock again...")
time.Sleep(10 * time.Millisecond)
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
// tasks before stopping and restarting the process.
func (s *processSupervisor) doRestart(ctx context.Context) error {
@@ -506,14 +346,14 @@ func (s *processSupervisor) ReqQueueSize() int64 {
return s.reqQueueSize.Load()
}
func (s *processSupervisor) RestartsCount() int64 {
return s.restartsCounter.Load()
}
func (s *processSupervisor) ActiveTasksCount() int64 {
return s.activeTasks.Load()
}
func (s *processSupervisor) RestartsCount() int64 {
return s.restartsCounter.Load()
}
// Interface guards.
var (
_ ProcessSupervisor = (*processSupervisor)(nil)

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"log/slog"
"os"
"sync"
"sync/atomic"
"testing"
@@ -37,7 +38,7 @@ func TestProcessSupervisor_Launch(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{
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 {
ps.firstStart.Store(true)
}
@@ -85,7 +86,7 @@ func TestProcessSupervisor_Shutdown(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{
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()
if !tc.expectError && err != nil {
@@ -134,7 +135,7 @@ func TestProcessSupervisor_restart(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{
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()
@@ -169,15 +170,15 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
expectHealthy bool
}{
{
scenario: "non-started process is healthy",
scenario: "non-started process is always healthy",
initiallyStarted: false,
expectHealthy: true,
},
{
scenario: "restarting process is not healthy",
scenario: "restarting process is always healthy",
initiallyStarted: true,
initiallyRestarting: true,
expectHealthy: false,
expectHealthy: true,
},
{
scenario: "process reports as healthy",
@@ -193,7 +194,7 @@ func TestProcessSupervisor_Healthy(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{
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 {
ps.firstStart.Store(true)
}
@@ -364,7 +365,7 @@ func TestProcessSupervisor_Run(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
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 {
ps.firstStart.Store(true)
}
@@ -470,7 +471,7 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
},
} {
t.Run(tc.scenario, func(t *testing.T) {
ps := NewProcessSupervisor(slog.New(slog.DiscardHandler), new(ProcessMock), 0, 0, 1, 0).(*processSupervisor)
ps := NewProcessSupervisor(slog.New(slog.NewJSONHandler(os.Stdout, nil)), new(ProcessMock), 0, 0, 1).(*processSupervisor)
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
if tc.ctxDone {
@@ -495,7 +496,7 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
}
func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
process := &ProcessMock{
StartMock: func(logger *slog.Logger) error {
return nil
@@ -504,7 +505,7 @@ func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
return true
},
}
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, 0, 0, 1).(*processSupervisor)
// Simulating a lock.
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) {
for _, tc := range []struct {
scenario string
@@ -697,7 +593,7 @@ func TestProcessSupervisor_RestartsCount(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{
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)
for i := 0; i < tc.restartAttempts; i++ {
@@ -724,7 +620,7 @@ func TestProcessSupervisor_RestartsCount(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
process := &ProcessMock{
@@ -741,7 +637,7 @@ func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
}
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)
defer cancel()
@@ -788,7 +684,7 @@ func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
}
func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
process := &ProcessMock{
StartMock: func(logger *slog.Logger) error {
@@ -803,7 +699,7 @@ func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) {
}
maxConcurrency := int64(3)
ps := NewProcessSupervisor(logger, process, 3, 0, maxConcurrency, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, 3, 0, maxConcurrency).(*processSupervisor)
ps.firstStart.Store(true)
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())
}
}
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)
}

View File

@@ -11,6 +11,7 @@ import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/trace"
"go.uber.org/multierr"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg/internal/log"
internalotel "github.com/gotenberg/gotenberg/v8/pkg/gotenberg/internal/otel"
@@ -54,13 +55,13 @@ func (cfg TelemetryConfig) Validate() error {
var err error
if cfg.ServiceName == "" {
err = errors.Join(err,
err = multierr.Append(err,
errors.New("service name must not be empty"),
)
}
if cfg.ServiceVersion == "" {
err = errors.Join(err,
err = multierr.Append(err,
errors.New("service version must not be empty"),
)
}
@@ -69,7 +70,7 @@ func (cfg TelemetryConfig) Validate() error {
case ErrorLoggingLevel, WarnLoggingLevel, InfoLoggingLevel, DebugLoggingLevel:
break
default:
err = errors.Join(
err = multierr.Append(
err,
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:
break
default:
err = errors.Join(
err = multierr.Append(
err,
fmt.Errorf("standard log format must be either %s, %s or %s", AutoLoggingFormat, JsonLoggingFormat, TextLoggingFormat),
)

View File

@@ -7,6 +7,7 @@ import (
"log/slog"
"net"
"net/http"
"os"
"sort"
"strings"
"time"
@@ -15,6 +16,7 @@ import (
"github.com/dlclark/regexp2"
"github.com/labstack/echo/v4"
flag "github.com/spf13/pflag"
"go.uber.org/multierr"
"golang.org/x/net/http2"
"golang.org/x/sync/errgroup"
@@ -26,7 +28,7 @@ func init() {
}
// Api is a module that provides an HTTP server. Other modules may add routes,
// middlewares or health checks.
// middlewares, or health checks.
type Api struct {
port int
bindIp string
@@ -36,14 +38,14 @@ type Api struct {
bodyLimit int64
timeout time.Duration
rootPath string
correlationIdHeader string
basicAuthUsername string
basicAuthPassword string
correlationIdHeader string
downloadFromCfg downloadFromConfig
disableHealthCheckRouteTelemetry bool
disableRootRouteTelemetry bool
disableDebugRouteTelemetry bool
disableVersionRouteTelemetry bool
disableDebugRouteTelemetry bool
enableDebugRoute bool
routes []Route
@@ -57,12 +59,10 @@ type Api struct {
}
type downloadFromConfig struct {
allowList []*regexp2.Regexp
denyList []*regexp2.Regexp
denyPrivateIPs bool
denyPublicIPs bool
maxRetry int
disable bool
allowList *regexp2.Regexp
denyList *regexp2.Regexp
maxRetry int
disable bool
}
// Router is a module interface that adds routes to the [Api].
@@ -84,8 +84,7 @@ type Route struct {
// Optional.
IsMultipart bool
// DisableTelemetry disables telemetry (logging, tracing, metrics) for
// this route.
// DisableTelemetry disables the telemetry and logging for this route.
// Optional.
DisableTelemetry bool
@@ -123,29 +122,6 @@ const (
// Middleware is a middleware that can be added to the [Api]'s middlewares
// chain.
//
// middleware := Middleware{
// Handler: func() echo.MiddlewareFunc {
// return func(next echo.HandlerFunc) echo.HandlerFunc {
// return func(c echo.Context) error {
// rootPath := c.Get("rootPath").(string)
// healthURI := fmt.Sprintf("%shealth", rootPath)
//
// // Skip the middleware if health check URI.
// if c.Request().RequestURI == healthURI {
// // Call the next middleware in the chain.
// return next(c)
// }
//
// // Your middleware process.
// // ...
//
// // Call the next middleware in the chain.
// return next(c)
// }
// }
// }(),
// }
type Middleware struct {
// Stack tells in which stack the middleware should be located.
// Default to [DefaultStack].
@@ -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.String("api-body-limit", "", "Set the body limit for multipart/form-data requests - it accepts values like 5MB, 1GB, etc")
fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths")
fs.String("api-correlation-id-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables")
fs.StringSlice("api-download-from-allow-list", []string{}, "Set the allowed URLs for the download from feature using regular expressions - supports multiple values")
fs.StringSlice("api-download-from-deny-list", []string{}, "Set the denied URLs for the download from feature using regular expressions - supports multiple values")
fs.Bool("api-download-from-deny-private-ips", false, "Reject downloadFrom URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted downloadFrom sources to mitigate SSRF against internal services")
fs.Bool("api-download-from-deny-public-ips", false, "Reject downloadFrom URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent downloads from reaching the public internet")
fs.String("api-correlation-id-header", "X-Correlation-ID", "Set the header name to use to set the correlation id in the logs")
fs.String("api-download-from-allow-list", "", "Set the allowed URLs for the download from feature using a regular expression")
fs.String("api-download-from-deny-list", "", "Set the denied URLs for the download from feature using a regular expression")
fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature")
fs.Bool("api-disable-download-from", false, "Disable the download from feature")
fs.Bool("api-disable-health-check-route-telemetry", true, "Disable telemetry for health check route")
fs.Bool("api-disable-root-route-telemetry", true, "Disable telemetry for the root route")
fs.Bool("api-disable-debug-route-telemetry", true, "Disable telemetry for the debug route")
fs.Bool("api-disable-version-route-telemetry", true, "Disable telemetry for the version route")
fs.Bool("api-disable-health-check-route-telemetry", false, "Disable the health check route telemetry")
fs.Bool("api-disable-root-route-telemetry", false, "Disable the root route telemetry")
fs.Bool("api-disable-version-route-telemetry", false, "Disable the version route telemetry")
fs.Bool("api-disable-debug-route-telemetry", false, "Disable the debug route telemetry")
fs.Bool("api-enable-debug-route", false, "Enable the debug route")
// Deprecated flags.
fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
fs.Bool("api-disable-health-check-logging", false, "Disable health check logging")
err := errors.Join(
fs.MarkDeprecated("api-trace-header", "use --api-correlation-id-header instead"),
fs.MarkDeprecated("api-disable-health-check-logging", "use --api-disable-health-check-route-telemetry instead"),
)
if err != nil {
panic(err)
}
return fs
}(),
New: func() gotenberg.Module { return new(Api) },
@@ -237,19 +200,17 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
a.timeout = flags.MustDuration("api-timeout")
a.bodyLimit = flags.MustHumanReadableBytes("api-body-limit")
a.rootPath = flags.MustString("api-root-path")
a.correlationIdHeader = flags.MustDeprecatedString("api-trace-header", "api-correlation-id-header")
a.correlationIdHeader = flags.MustString("api-correlation-id-header")
a.downloadFromCfg = downloadFromConfig{
allowList: flags.MustRegexpSlice("api-download-from-allow-list"),
denyList: flags.MustRegexpSlice("api-download-from-deny-list"),
denyPrivateIPs: flags.MustBool("api-download-from-deny-private-ips"),
denyPublicIPs: flags.MustBool("api-download-from-deny-public-ips"),
maxRetry: flags.MustInt("api-download-from-max-retry"),
disable: flags.MustBool("api-disable-download-from"),
allowList: flags.MustRegexp("api-download-from-allow-list"),
denyList: flags.MustRegexp("api-download-from-deny-list"),
maxRetry: flags.MustInt("api-download-from-max-retry"),
disable: flags.MustBool("api-disable-download-from"),
}
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.disableDebugRouteTelemetry = flags.MustBool("api-disable-debug-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")
// Port from env?
@@ -368,39 +329,33 @@ func (a *Api) Validate() error {
var err error
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"),
)
}
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 != "") {
err = errors.Join(err,
err = multierr.Append(err,
errors.New("both TLS certificate and key files must be set"),
)
}
if !strings.HasPrefix(a.rootPath, "/") {
err = errors.Join(err,
err = multierr.Append(err,
errors.New("root path must start with /"),
)
}
if !strings.HasSuffix(a.rootPath, "/") {
err = errors.Join(err,
err = multierr.Append(err,
errors.New("root path must end with /"),
)
}
if len(strings.TrimSpace(a.correlationIdHeader)) == 0 {
err = errors.Join(err,
errors.New("trace header must not be empty"),
)
}
if err != nil {
return err
}
@@ -467,29 +422,40 @@ func (a *Api) Start() error {
disableTelemetryForPaths = append(disableTelemetryForPaths, strings.TrimPrefix(route.Path, "/"))
}
}
// Check if the user wishes to disable telemetry for specific routes.
// Check if the user wishes to disable telemetry for system routes.
// Note: root path will be used as prefix in the underlying middlewares.
if a.disableHealthCheckRouteTelemetry {
disableTelemetryForPaths = append(disableTelemetryForPaths, "health")
}
if a.disableRootRouteTelemetry {
disableTelemetryForPaths = append(disableTelemetryForPaths, "")
}
if a.disableDebugRouteTelemetry {
disableTelemetryForPaths = append(disableTelemetryForPaths, "debug")
}
if a.disableVersionRouteTelemetry {
disableTelemetryForPaths = append(disableTelemetryForPaths, "version")
}
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.
hostname, err := os.Hostname()
if err != nil {
return fmt.Errorf("get hostname: %w", err)
}
a.srv.Pre(
latencyMiddleware(),
rootPathMiddleware(a.rootPath),
outputFilenameMiddleware(),
telemetryMiddleware(a.logger, serverName, a.correlationIdHeader, disableTelemetryForPaths),
telemetryMiddleware(a.logger, hostname, a.correlationIdHeader, disableTelemetryForPaths),
)
// 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...
checks := make([]health.CheckerOption, len(a.healthChecks), len(a.healthChecks)+1)
copy(checks, a.healthChecks)
checks = append(checks, health.WithTimeout(a.timeout))
checks := append(a.healthChecks, health.WithTimeout(a.timeout))
checker := health.NewChecker(checks...)
healthCheckHandler := health.NewHandler(checker)
@@ -611,7 +575,7 @@ func (a *Api) Start() error {
eg.Go(f)
}
err := eg.Wait()
err = eg.Wait()
if err != nil {
return fmt.Errorf("waiting for modules readiness: %w", err)
}
@@ -629,6 +593,7 @@ func (a *Api) Start() error {
}
if !errors.Is(err, http.ErrServerClosed) {
a.logger.ErrorContext(context.Background(), err.Error())
os.Exit(1)
}
}()

View File

@@ -21,14 +21,13 @@ import (
"github.com/labstack/echo/v4"
"github.com/mholt/archives"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/propagation"
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
"go.opentelemetry.io/otel/trace"
"golang.org/x/sync/errgroup"
"golang.org/x/text/unicode/norm"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
semconvutil "github.com/gotenberg/gotenberg/v8/pkg/gotenberg/semconv"
)
var (
@@ -43,13 +42,12 @@ var (
// Context is the request context for a "multipart/form-data" request.
type Context struct {
dirPath string
values map[string][]string
files map[string]string
filesByField map[string][]string
diskToOriginal map[string]string
outputPaths []string
cancelled bool
dirPath string
values map[string][]string
files map[string]string
filesByField map[string][]string
outputPaths []string
cancelled bool
logger *slog.Logger
echoCtx echo.Context
@@ -87,14 +85,8 @@ type downloadFrom struct {
// ExtraHttpHeaders are the HTTP headers to send alongside.
ExtraHttpHeaders map[string]string `json:"extraHttpHeaders"`
// Embedded routes the downloaded file as an embed. Deprecated: use
// Field instead. Kept for backward compatibility.
Embedded bool `json:"embedded"`
// Field routes the downloaded file to a specific form field bucket.
// Supported values: "watermark", "stamp". For embeds, prefer the
// Embedded flag or set Field to "embedded".
Field string `json:"field"`
// Download as an attachment file.
Attachment bool `json:"attachment"`
}
// 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)
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
}
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
}
}()
@@ -201,7 +193,6 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
ctx.values = form.Value
ctx.files = make(map[string]string)
ctx.filesByField = make(map[string][]string)
ctx.diskToOriginal = make(map[string]string)
// First, try to download files listed in the "downloadFrom" form field, if
// any.
@@ -232,27 +223,15 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
)
}
ipOpts := []gotenberg.DecideOption{
gotenberg.WithDenyPrivateIPs(downloadFromCfg.denyPrivateIPs),
gotenberg.WithDenyPublicIPs(downloadFromCfg.denyPublicIPs),
}
err := gotenberg.FilterOutboundURL(ctx, dl.Url, downloadFromCfg.allowList, downloadFromCfg.denyList, deadline, ipOpts...)
err := gotenberg.FilterDeadline(downloadFromCfg.allowList, downloadFromCfg.denyList, dl.Url, deadline)
if err != nil {
return fmt.Errorf("filter URL: %w", err)
}
dlCtx, dlSpan := gotenberg.Tracer().Start(ctx, "GET Download From",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(dl.Url)),
)
logger.DebugContext(ctx, fmt.Sprintf("download file from '%s'", dl.Url))
logger.DebugContext(dlCtx, fmt.Sprintf("download file from '%s'", dl.Url))
req, err := retryablehttp.NewRequest(http.MethodGet, dl.Url, nil)
req, err := retryablehttp.NewRequestWithContext(ctx, http.MethodGet, dl.Url, nil)
if err != nil {
dlSpan.RecordError(err)
dlSpan.SetStatus(codes.Error, err.Error())
dlSpan.End()
return fmt.Errorf("create request to '%s': %w", dl.Url, err)
}
@@ -260,37 +239,45 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
for key, value := range dl.ExtraHttpHeaders {
req.Header.Set(key, value)
}
req.Header.Set(echoCtx.Get("correlationIdHeader").(string), echoCtx.Get("correlationId").(string))
// Inject OTEL trace context into outbound request.
otel.GetTextMapPropagator().Inject(dlCtx, propagation.HeaderCarrier(req.Header))
// OpenTelemetry.
meter := gotenberg.Meter()
semconvClient := semconvutil.NewHTTPClient(meter)
// Propagate correlation ID header.
if correlationIdHeader, ok := echoCtx.Get("correlationIdHeader").(string); ok {
if correlationId, ok := echoCtx.Get("correlationId").(string); ok {
req.Header.Set(correlationIdHeader, correlationId)
}
}
tracer := gotenberg.Tracer()
traceCtx, span := tracer.Start(ctx, fmt.Sprintf("%s Download From", req.Method),
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconvClient.RequestTraceAttrs(req.Request)...),
)
defer span.End()
otel.GetTextMapPropagator().Inject(traceCtx, propagation.HeaderCarrier(req.Header))
client := &retryablehttp.Client{
HTTPClient: gotenberg.NewOutboundHttpClient(time.Until(deadline), downloadFromCfg.allowList, downloadFromCfg.denyList, ipOpts...),
HTTPClient: &http.Client{
Timeout: time.Until(deadline),
},
RetryMax: downloadFromCfg.maxRetry,
RetryWaitMin: time.Duration(1) * time.Second,
RetryWaitMax: time.Until(deadline),
Logger: gotenberg.NewLeveledLogger(logger),
Logger: gotenberg.NewLeveledLogger(logger).WithContext(ctx),
CheckRetry: retryablehttp.DefaultRetryPolicy,
Backoff: retryablehttp.DefaultBackoff,
}
resp, err := client.Do(req)
if err != nil {
dlSpan.RecordError(err)
dlSpan.SetStatus(codes.Error, err.Error())
dlSpan.End()
span.RecordError(err)
span.SetStatus(semconvClient.Status(0))
return WrapError(
fmt.Errorf("download file from to '%s': %w", dl.Url, err),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Unable to download file from '%s': %s", dl.Url, err)),
)
}
span.SetAttributes(semconvClient.ResponseTraceAttrs(resp)...)
span.SetStatus(semconvClient.Status(resp.StatusCode))
defer func() {
err := resp.Body.Close()
if err != nil {
@@ -299,24 +286,16 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
}()
if resp.StatusCode != http.StatusOK {
dlErr := fmt.Errorf("download file from to '%s': got status: '%s'", dl.Url, resp.Status)
dlSpan.RecordError(dlErr)
dlSpan.SetStatus(codes.Error, dlErr.Error())
dlSpan.End()
return WrapError(
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)),
)
}
contentDisposition := resp.Header.Get("Content-Disposition")
if contentDisposition == "" {
dlErr := fmt.Errorf("no 'Content-Disposition' header from '%s'", dl.Url)
dlSpan.RecordError(dlErr)
dlSpan.SetStatus(codes.Error, dlErr.Error())
dlSpan.End()
return WrapError(
dlErr,
fmt.Errorf("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.
_, params, err := mime.ParseMediaType(contentDisposition)
if err != nil {
dlErr := fmt.Errorf("parse 'Content-Disposition' header '%s' from '%s': %w", contentDisposition, dl.Url, err)
dlSpan.RecordError(dlErr)
dlSpan.SetStatus(codes.Error, dlErr.Error())
dlSpan.End()
return WrapError(
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)),
)
}
filename, ok := params["filename"]
if !ok {
dlErr := fmt.Errorf("get filename from 'Content-Disposition' header '%s' from '%s'", contentDisposition, dl.Url)
dlSpan.RecordError(dlErr)
dlSpan.SetStatus(codes.Error, dlErr.Error())
dlSpan.End()
return WrapError(
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)),
)
}
@@ -352,20 +323,11 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
// normalized.
// See: https://github.com/gotenberg/gotenberg/issues/662.
filename = norm.NFC.String(filepath.Base(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)
path := fmt.Sprintf("%s/%s", dirPath, filename)
out, err := os.Create(path)
if err != nil {
dlErr := fmt.Errorf("create local file: %w", err)
dlSpan.RecordError(dlErr)
dlSpan.SetStatus(codes.Error, dlErr.Error())
dlSpan.End()
return dlErr
return fmt.Errorf("create local file: %w", err)
}
defer func() {
err := out.Close()
@@ -379,27 +341,12 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
_, err = io.Copy(out, reader)
if err != nil {
dlErr := fmt.Errorf("copy downloaded file from '%s' to local file: %w", dl.Url, err)
dlSpan.RecordError(dlErr)
dlSpan.SetStatus(codes.Error, dlErr.Error())
dlSpan.End()
return dlErr
return fmt.Errorf("copy downloaded file from '%s' to local file: %w", dl.Url, err)
}
dlSpan.SetStatus(codes.Ok, "")
dlSpan.End()
ctx.files[filename] = path
ctx.diskToOriginal[path] = filename
// 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)
if dl.Attachment {
ctx.filesByField[AttachmentsFormField] = append(ctx.filesByField[AttachmentsFormField], path)
}
return nil
@@ -421,7 +368,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
defer func() {
err := in.Close()
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.
// See: https://github.com/gotenberg/gotenberg/issues/662.
filename := norm.NFC.String(filepath.Base(fh.Filename))
// Use a UUID-based name on disk to avoid filesystem
// NAME_MAX limits with long filenames.
// See: https://github.com/gotenberg/gotenberg/issues/1500.
safeName := uuid.New().String() + filepath.Ext(filename)
path := fmt.Sprintf("%s/%s", ctx.dirPath, safeName)
path := fmt.Sprintf("%s/%s", ctx.dirPath, filename)
out, err := os.Create(path)
if err != nil {
@@ -446,7 +388,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
defer func() {
err := out.Close()
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.diskToOriginal[path] = filename
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 files: %+v", ctx.files))
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].
func (ctx *Context) FormData() *FormData {
return &FormData{
values: ctx.values,
files: ctx.files,
filesByField: ctx.filesByField,
diskToOriginal: ctx.diskToOriginal,
errors: nil,
values: ctx.values,
files: ctx.files,
filesByField: ctx.filesByField,
errors: nil,
}
}
// OriginalFilename returns the original filename associated with a disk path.
// If no mapping exists, it falls back to [filepath.Base].
func (ctx *Context) OriginalFilename(diskPath string) string {
if original, ok := ctx.diskToOriginal[diskPath]; ok {
return original
}
return filepath.Base(diskPath)
}
// RegisterDiskPath associates a disk path with an original filename so that
// [Context.OriginalFilename] can resolve it later.
func (ctx *Context) RegisterDiskPath(diskPath, originalFilename string) {
ctx.diskToOriginal[diskPath] = originalFilename
}
// DirPath returns the path to the request's working directory.
func (ctx *Context) DirPath() string {
return ctx.dirPath
}
// GeneratePath generates a path within the context's working directory.
// It generates a new UUID-based filename. It does not create a file.
func (ctx *Context) GeneratePath(extension string) string {
@@ -543,14 +446,10 @@ func (ctx *Context) GeneratePath(extension string) string {
}
// GeneratePathFromFilename generates a path within the context's working
// directory. It uses a UUID-based name on disk to avoid filesystem NAME_MAX
// limits but registers the given filename so that [Context.OriginalFilename]
// can resolve it. It does not create a file.
// directory, using the given filename (with extension). It does not create
// a file.
func (ctx *Context) GeneratePathFromFilename(filename string) string {
safeName := uuid.New().String() + filepath.Ext(filename)
path := fmt.Sprintf("%s/%s", ctx.dirPath, safeName)
ctx.diskToOriginal[path] = filename
return path
return fmt.Sprintf("%s/%s", ctx.dirPath, filename)
}
// 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 {
f := make(map[string]string)
for _, outputPath := range ctx.outputPaths {
f[outputPath] = ctx.OriginalFilename(outputPath)
f[outputPath] = ""
}
return f
}())
@@ -653,7 +552,7 @@ func (ctx *Context) OutputFilename(outputPath string) string {
filename := ctx.echoCtx.Get("outputFilename").(string)
if filename == "" {
return ctx.OriginalFilename(outputPath)
return filepath.Base(outputPath)
}
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))

View File

@@ -7,6 +7,7 @@ import (
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
@@ -35,7 +36,7 @@ func TestNewContext_Cancellation(t *testing.T) {
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
logger := slog.New(slog.DiscardHandler)
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
timeout := time.Duration(10) * time.Second
downloadFromCfg := downloadFromConfig{

View File

@@ -1,3 +1,3 @@
// Package api implements the HTTP server module. Other modules register
// multipart/form data routes, middlewares, and health checks through it.
// Package api provides a module, which is an HTTP server. Other modules may
// add multipart/form-data routes, middlewares, and health checks.
package api

View File

@@ -1,8 +1,6 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
@@ -14,18 +12,14 @@ import (
"strings"
"time"
"go.uber.org/multierr"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// AttachmentsFormField represents the form field name for attaching files.
const (
// EmbedsFormField represents the form field name for embedding files.
EmbedsFormField string = "embeds"
// WatermarkFormField represents the form field name for the watermark file.
WatermarkFormField string = "watermark"
// StampFormField represents the form field name for the stamp file.
StampFormField string = "stamp"
AttachmentsFormField string = "attachments"
)
// FormData is a helper for validating and hydrating values from a
@@ -33,11 +27,10 @@ const (
//
// form := ctx.FormData()
type FormData struct {
values map[string][]string
files map[string]string
filesByField map[string][]string
diskToOriginal map[string]string
errors error
values map[string][]string
files map[string]string
filesByField map[string][]string
errors error
}
// 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)
}
// Embeds binds the absolute paths of form data files that should be
// embedded in the PDF. Only files uploaded with the "embeds" field name
// Attachments binds the absolute paths of form data files that should be
// attached in the PDF. Only files uploaded with the "attachments" field name
// will be included.
//
// var embeds []string
// var attachments []string
//
// ctx.FormData().Embeds(&embeds)
func (form *FormData) Embeds(target *[]string) *FormData {
// ctx.FormData().Attachments(&attachments)
func (form *FormData) Attachments(target *[]string) *FormData {
if form.errors != nil {
return form
}
// Get files from the "embeds" field
if paths, ok := form.filesByField[EmbedsFormField]; ok {
// Get files from the "attachments" field
if paths, ok := form.filesByField[AttachmentsFormField]; ok {
*target = append(*target, paths...)
}
return form
}
// EmbedsMetadata parses the "embedsMetadata" form field (a JSON string) into
// a map keyed by filename. Each value is a map of property names to values
// (e.g., "mimeType" and "relationship").
//
// var metadata map[string]map[string]string
//
// ctx.FormData().EmbedsMetadata(&metadata)
func (form *FormData) EmbedsMetadata(target *map[string]map[string]string) *FormData {
if form.errors != nil {
return form
}
val, ok := form.values["embedsMetadata"]
if !ok || len(val) == 0 || val[0] == "" {
return form
}
raw := val[0]
parsed := make(map[string]map[string]string)
err := json.Unmarshal([]byte(raw), &parsed)
if err != nil {
form.append(
fmt.Errorf("form field 'embedsMetadata' is invalid: %w", err),
)
return form
}
*target = parsed
return form
}
// MandatoryPaths binds the absolute paths of form data files, according to a
// list of file extensions, to a string slice variable. It populates an error
// if there is no file for given file extensions.
@@ -445,96 +406,34 @@ func (form *FormData) MandatoryPaths(extensions []string, target *[]string) *For
return form
}
// Watermark binds the absolute path of the form data file that should be
// used as a watermark source. Only a file uploaded with the "watermark"
// field name will be included.
func (form *FormData) Watermark(target *string) *FormData {
if form.errors != nil {
return form
}
if paths, ok := form.filesByField[WatermarkFormField]; ok && len(paths) > 0 {
*target = paths[0]
}
return form
}
// Stamp binds the absolute path of the form data file that should be
// used as a stamp source. Only a file uploaded with the "stamp"
// field name will be included.
func (form *FormData) Stamp(target *string) *FormData {
if form.errors != nil {
return form
}
if paths, ok := form.filesByField[StampFormField]; ok && len(paths) > 0 {
*target = paths[0]
}
return form
}
// paths bind the absolute paths of form data files, according to a list of
// file extensions, to a string slice variable.
// embeds, watermark, and stamp files are excluded.
// attachments are excluded.
func (form *FormData) paths(extensions []string, target *[]string) *FormData {
embeds, ok := form.filesByField[EmbedsFormField]
watermarks, wmOk := form.filesByField[WatermarkFormField]
stamps, stOk := form.filesByField[StampFormField]
// 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
attachments, ok := form.filesByField[AttachmentsFormField]
for filename, path := range form.files {
if ok && slices.Contains(embeds, path) {
continue
}
if wmOk && slices.Contains(watermarks, path) {
continue
}
if stOk && slices.Contains(stamps, path) {
if ok && slices.Contains(attachments, path) {
continue
}
for _, ext := range extensions {
// See https://github.com/gotenberg/gotenberg/issues/228.
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.
originals := make(gotenberg.AlphanumericSort, len(entries))
for i, e := range entries {
originals[i] = e.original
}
sort.Sort(originals)
// Build a lookup from original name to disk path.
lookup := make(map[string]string, len(entries))
for _, e := range entries {
lookup[e.original] = e.disk
}
for _, o := range originals {
*target = append(*target, lookup[o])
}
sort.Sort(gotenberg.AlphanumericSort(*target))
return form
}
// append adds an error to the list of errors.
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

View File

@@ -1613,15 +1613,15 @@ func TestFormData_Paths(t *testing.T) {
expectCount: 2,
},
{
scenario: "files except embeds",
scenario: "files except attachments",
form: &FormData{
files: map[string]string{
"foo.pdf": "/foo.pdf",
"embed_1.pdf": "/embed_1.pdf",
"embed_2.xml": "/embed_2.xml",
"foo.pdf": "/foo.pdf",
"attachments_1.pdf": "/attachments_1.pdf",
"attachments_2.xml": "/attachments_2.xml",
},
filesByField: map[string][]string{
"embeds": {"/embed_1.pdf", "/embed_2.xml"},
"attachments": {"/attachments_1.pdf", "/attachments_2.xml"},
},
},
extensions: []string{".pdf"},
@@ -1759,7 +1759,7 @@ func TestFormData_mustAssign(t *testing.T) {
form.mustAssign("foo", "foo", &target)
}
func TestFormData_Embeds(t *testing.T) {
func TestFormData_Attachments(t *testing.T) {
expected := []string{"/bar.xml", "/baz.xml"}
var actual []string
@@ -1770,13 +1770,13 @@ func TestFormData_Embeds(t *testing.T) {
"baz.xml": "/baz.xml",
},
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) {
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) {

View File

@@ -5,6 +5,7 @@ import (
"crypto/subtle"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"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"
}
if errors.Is(err, gotenberg.ErrPdfStampSourceNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested stamp source type, while others may have failed due to different issues"
}
if errors.Is(err, gotenberg.ErrPdfRotateAngleNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested rotation angle, while others may have failed due to different issues"
}
if invalidArgsError, ok := errors.AsType[*gotenberg.PdfEngineInvalidArgsError](err); ok {
var invalidArgsError *gotenberg.PdfEngineInvalidArgsError
if errors.As(err, &invalidArgsError) {
return http.StatusBadRequest, invalidArgsError.Error()
}
@@ -203,7 +197,7 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
}
if skipTelemetry {
c.Set("logger", slog.New(slog.DiscardHandler))
c.Set("logger", slog.New(slog.NewJSONHandler(io.Discard, nil)))
err := next(c)
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 {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
logger, _ := c.Get("logger").(*slog.Logger)
if logger == nil {
return errors.New("no logger in context (possible pool reuse)")
}
logger := c.Get("logger").(*slog.Logger)
// We create a context with a timeout so that underlying processes are
// 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 {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Guard the type assertion so a pooled [echo.Context] whose
// store has been recycled under us does not crash the process.
// See the webhook async handler for the race this protects
// against.
logger, _ := c.Get("logger").(*slog.Logger)
if logger == nil {
return errors.New("no logger in context (possible pool reuse)")
}
logger := c.Get("logger").(*slog.Logger)
// Define a hard timeout if the route handler fails to timeout as
// expected.

View File

@@ -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)
}
}

View File

@@ -74,7 +74,7 @@ func (ctx *ContextMock) OutputPaths() []string {
// SetLogger sets the logger.
//
// 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) {
ctx.logger = logger
}

View File

@@ -19,6 +19,10 @@ import (
"github.com/chromedp/chromedp"
"github.com/dlclark/regexp2"
"github.com/shirou/gopsutil/v4/process"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.39.0"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
@@ -42,10 +46,8 @@ type browserArguments struct {
hyphenDataDirPath string
// Tasks specific.
allowList []*regexp2.Regexp
denyList []*regexp2.Regexp
denyPrivateIPs bool
denyPublicIPs bool
allowList *regexp2.Regexp
denyList *regexp2.Regexp
clearCache bool
clearCookies bool
disableJavaScript bool
@@ -59,17 +61,15 @@ type chromiumBrowser struct {
ctxMu sync.RWMutex
isStarted atomic.Bool
arguments browserArguments
fs *gotenberg.FileSystem
pinningProxy *pinningProxy
arguments browserArguments
fs *gotenberg.FileSystem
}
func newChromiumBrowser(arguments browserArguments) browser {
b := &chromiumBrowser{
initialCtx: context.Background(),
arguments: arguments,
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
pinningProxy: newPinningProxy(arguments.allowList, arguments.denyList, arguments.denyPrivateIPs, arguments.denyPublicIPs),
initialCtx: context.Background(),
arguments: arguments,
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
}
b.isStarted.Store(false)
@@ -81,7 +81,10 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
return errors.New("browser is already started")
}
debug := &debugLogger{logger: logger}
debug := &debugLogger{
ctx: b.initialCtx,
logger: logger,
}
b.userProfileDirPath = b.fs.NewDirPath()
// 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))
}
// Default: route Chromium through the internal pinning proxy so that
// Chromium never performs its own DNS lookup for the navigation URL
// or any sub-resource. The proxy resolves and validates each URL
// once per request and dials the pinned IP, closing the DNS
// rebinding window between Gotenberg's validation and Chromium's
// connect.
//
// Skip when the operator has configured their own egress proxy or
// custom host-resolver mappings: those deployments take
// responsibility for outbound safety themselves and routing through
// an internal proxy would override their configuration.
if b.arguments.proxyServer == "" && b.arguments.hostResolverRules == "" {
err = b.pinningProxy.Start(logger)
if err != nil {
return fmt.Errorf("start pinning proxy: %w", err)
}
opts = append(opts, chromedp.ProxyServer(b.pinningProxy.URL()))
}
// See https://github.com/gotenberg/gotenberg/issues/524.
opts = append(opts, chromedp.WSURLReadTimeout(b.arguments.wsUrlReadTimeout))
@@ -222,9 +206,9 @@ func (b *chromiumBrowser) Stop(logger *slog.Logger) error {
err = p.KillWithContext(killCtx)
if err != nil {
logger.ErrorContext(context.Background(), fmt.Sprintf("kill process: %v", err))
logger.ErrorContext(killCtx, fmt.Sprintf("kill process: %v", err))
} 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.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
}
@@ -298,7 +273,7 @@ func (b *chromiumBrowser) Healthy(logger *slog.Logger) bool {
return err
}))
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
}
@@ -368,12 +343,8 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
return errors.New("context has no deadline")
}
// We validate the "main" URL against our allowed / deny lists, and
// against the IP-based outbound URL guard. See [gotenberg.FilterOutboundURL].
err := gotenberg.FilterOutboundURL(ctx, url, b.arguments.allowList, b.arguments.denyList, deadline,
gotenberg.WithDenyPrivateIPs(b.arguments.denyPrivateIPs),
gotenberg.WithDenyPublicIPs(b.arguments.denyPublicIPs),
)
// We validate the "main" URL against our allowed / deny lists.
err := gotenberg.FilterDeadline(b.arguments.allowList, b.arguments.denyList, url, deadline)
if err != nil {
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.
// See https://github.com/gotenberg/gotenberg/issues/1011.
listenForEventRequestPaused(taskCtx, logger, eventRequestPausedOptions{
allowList: b.arguments.allowList,
denyList: b.arguments.denyList,
denyPrivateIPs: b.arguments.denyPrivateIPs,
denyPublicIPs: b.arguments.denyPublicIPs,
allowedFilePrefixes: options.AllowedFilePrefixes,
extraHttpHeaders: options.ExtraHttpHeaders,
allowList: b.arguments.allowList,
denyList: b.arguments.denyList,
extraHttpHeaders: options.ExtraHttpHeaders,
})
var (
@@ -420,7 +388,6 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
ignoreResourceHttpStatusDomains: options.IgnoreResourceHttpStatusDomains,
invalidResourceHttpStatusCode: &invalidResourceHttpStatusCode,
invalidResourceHttpStatusCodeMu: &invalidResourceHttpStatusCodeMu,
cancelOnMainPageError: taskCancel,
})
}
@@ -450,45 +417,30 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
loadingFailedMu: &loadingFailedMu,
resourceLoadingFailed: &resourceLoadingFailed,
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
// errors because they carry the actual root cause (e.g., HTTP 500 from
// the main page). When we cancel taskCtx on a main page error,
// chromedp.Run returns a context error that is less informative.
// See https://github.com/gotenberg/gotenberg/issues/1492.
// See https://github.com/gotenberg/gotenberg/issues/613.
invalidHttpStatusCodeMu.RLock()
defer invalidHttpStatusCodeMu.RUnlock()
if invalidHttpStatusCode != nil {
return fmt.Errorf("%v: %w", invalidHttpStatusCode, ErrInvalidHttpStatusCode)
err = chromedp.Run(clientCtx, tasks...)
if err != nil {
clientSpan.RecordError(err)
clientSpan.SetStatus(codes.Error, err.Error())
}
// See https://github.com/gotenberg/gotenberg/issues/1021.
invalidResourceHttpStatusCodeMu.RLock()
defer invalidResourceHttpStatusCodeMu.RUnlock()
clientSpan.End()
if invalidResourceHttpStatusCode != nil {
return fmt.Errorf("%v: %w", invalidResourceHttpStatusCode, ErrInvalidResourceHttpStatusCode)
}
// See:
// https://github.com/gotenberg/gotenberg/issues/913.
// https://github.com/gotenberg/gotenberg/issues/959.
loadingFailedMu.RLock()
defer loadingFailedMu.RUnlock()
if loadingFailed != nil {
return fmt.Errorf("%v: %w", loadingFailed, ErrLoadingFailed)
}
if runErr != nil {
errMessage := runErr.Error()
if err != nil {
errMessage := err.Error()
if strings.Contains(errMessage, "Printing failed (-32000)") {
return ErrPrintingFailed
@@ -510,7 +462,23 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
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.
@@ -521,6 +489,16 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
return fmt.Errorf("%v: %w", consoleExceptions, ErrConsoleExceptions)
}
// See:
// https://github.com/gotenberg/gotenberg/issues/913.
// https://github.com/gotenberg/gotenberg/issues/959.
loadingFailedMu.RLock()
defer loadingFailedMu.RUnlock()
if loadingFailed != nil {
return fmt.Errorf("%v: %w", loadingFailed, ErrLoadingFailed)
}
// See https://github.com/gotenberg/gotenberg/issues/1021.
if options.FailOnResourceLoadingFailed {
if resourceLoadingFailed != nil {

View File

@@ -18,8 +18,6 @@ import (
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/metric"
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
@@ -188,15 +186,6 @@ type Options struct {
// OmitBackground hides the default white background and allows generating
// PDFs with transparency.
OmitBackground bool
// AllowedFilePrefixes restricts file:// sub-resource access to only
// these directory prefixes. Applied in listenForEventRequestPaused in
// addition to the global allow/deny lists. An empty slice
// default-denies every file:// sub-resource, so routes that legitimately
// render local files (HTML, Markdown) must populate this with the
// request working directory while routes that navigate remote URLs
// leave it empty. Set internally by route handlers, not via form data.
AllowedFilePrefixes []string
}
// EmulatedMediaFeature gathers the available entries for emulating a media
@@ -294,7 +283,7 @@ type PdfOptions struct {
PreferCssPageSize bool
// GenerateDocumentOutline defines whether the document outline should be
// embedded into the PDF.
// attached into the PDF.
GenerateDocumentOutline bool
// GenerateTaggedPdf defines whether to generate tagged (accessible)
@@ -438,7 +427,6 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
fs := flag.NewFlagSet("chromium", flag.ExitOnError)
fs.Int64("chromium-restart-after", 100, "Number of conversions after which Chromium will automatically restart. Set to 0 to disable this feature")
fs.Int64("chromium-max-queue-size", 0, "Maximum request queue size for Chromium. Set to 0 to disable this feature")
fs.Duration("chromium-idle-shutdown-timeout", 0, "Shutdown Chromium after being idle for the given duration. Set to 0 to disable this feature")
fs.Int64("chromium-max-concurrency", 6, "Maximum number of concurrent conversions. Chromium supports up to 6")
fs.Bool("chromium-auto-start", false, "Automatically launch Chromium upon initialization if set to true; otherwise, Chromium will start at the time of the first conversion")
fs.Duration("chromium-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for Chromium to start or restart")
@@ -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.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.StringSlice("chromium-allow-list", []string{}, "Set the allowed URLs for Chromium using regular expressions - supports multiple values")
fs.StringSlice("chromium-deny-list", []string{`^file:(?!//\/tmp/).*`}, "Set the denied URLs for Chromium using regular expressions - supports multiple values")
fs.Bool("chromium-deny-private-ips", false, "Reject URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted form input to mitigate SSRF against internal services")
fs.Bool("chromium-deny-public-ips", false, "Reject URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent outbound traffic from leaving a private network")
fs.String("chromium-allow-list", "", "Set the allowed URLs for Chromium using a regular expression")
fs.String("chromium-deny-list", `^file:(?!//\/tmp/).*`, "Set the denied URLs for Chromium using a regular expression")
fs.Bool("chromium-clear-cache", false, "Clear Chromium cache between each conversion")
fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion")
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
fs.Bool("chromium-disable-routes", false, "Disable the routes")
// Deprecated flags.
fs.Bool("chromium-incognito", false, "Start Chromium with incognito mode")
err := fs.MarkDeprecated("chromium-incognito", "this flag is ignored as it provides no benefits")
if err != nil {
panic(err)
}
return fs
}(),
New: func() gotenberg.Module { return new(Chromium) },
@@ -498,10 +477,8 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
wsUrlReadTimeout: flags.MustDuration("chromium-start-timeout"),
hyphenDataDirPath: hyphenDataDirPath,
allowList: flags.MustRegexpSlice("chromium-allow-list"),
denyList: flags.MustRegexpSlice("chromium-deny-list"),
denyPrivateIPs: flags.MustBool("chromium-deny-private-ips"),
denyPublicIPs: flags.MustBool("chromium-deny-public-ips"),
allowList: flags.MustRegexp("chromium-allow-list"),
denyList: flags.MustRegexp("chromium-deny-list"),
clearCache: flags.MustBool("chromium-clear-cache"),
clearCookies: flags.MustBool("chromium-clear-cookies"),
disableJavaScript: flags.MustBool("chromium-disable-javascript"),
@@ -512,7 +489,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
// Process.
mod.browser = newChromiumBrowser(mod.args)
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"), mod.maxConcurrency, flags.MustDuration("chromium-idle-shutdown-timeout"))
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"), mod.maxConcurrency)
// PDF Engine.
provider, err := ctx.Module(new(gotenberg.PdfEngineProvider))
@@ -525,107 +502,104 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
}
mod.engine = engine
// Metrics.
// OpenTelemetry.
meter := gotenberg.Meter()
// Observable gauges.
_, err = meter.Int64ObservableGauge(
"chromium.requests.active",
metric.WithDescription("Current number of active Chromium requests"),
metric.WithUnit("{request}"),
_, err = meter.Int64ObservableCounter(
"chromium.process.restarts.total",
metric.WithDescription("Current number of Chromium restarts."),
metric.WithUnit("{restart}"),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
o.Observe(mod.supervisor.ActiveTasksCount())
val := mod.supervisor.RestartsCount()
o.Observe(val)
return 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(
"chromium.requests.queue_size",
metric.WithDescription("Current number of Chromium conversion requests waiting to be treated"),
metric.WithDescription("Current number of Chromium conversion requests waiting to be treated."),
metric.WithUnit("{request}"),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
o.Observe(mod.supervisor.ReqQueueSize())
val := mod.supervisor.ReqQueueSize()
o.Observe(val)
return 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(
"chromium.process.restarts.total",
metric.WithDescription("Current number of Chromium restarts"),
metric.WithUnit("{restart}"),
_, err = meter.Int64ObservableGauge(
"chromium.requests.active",
metric.WithDescription("Current number of Chromium conversion requests actively being processed."),
metric.WithUnit("{request}"),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
o.Observe(mod.supervisor.RestartsCount())
val := mod.supervisor.ActiveTasksCount()
o.Observe(val)
return 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(
"chromium.requests.total",
metric.WithDescription("Total number of Chromium conversion requests"),
metric.WithDescription("Total number of Chromium conversion requests."),
metric.WithUnit("{request}"),
)
if err != nil {
return fmt.Errorf("create chromium.requests.total counter: %w", err)
return fmt.Errorf("create requests total counter: %w", err)
}
mod.errsCounter, err = meter.Int64Counter(
"chromium.errors.total",
metric.WithDescription("Total number of Chromium conversion errors"),
metric.WithDescription("Total number of Chromium errors."),
metric.WithUnit("{error}"),
)
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(
"chromium.conversion.duration",
metric.WithDescription("Duration of Chromium conversions"),
metric.WithDescription("Duration of each HTML-to-PDF conversion."),
metric.WithUnit("s"),
durationBuckets,
metric.WithExplicitBucketBoundaries(0.5, 1, 2, 5, 10, 30, 60),
)
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(
"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"),
durationBuckets,
metric.WithExplicitBucketBoundaries(0.5, 1, 2, 5, 10, 30, 60),
)
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(
"chromium.pdf.output.size",
metric.WithDescription("Size of PDF output from Chromium conversions"),
metric.WithDescription("Size of the generated PDF files."),
metric.WithUnit("By"),
)
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(
"chromium.image.output.size",
metric.WithDescription("Size of image output from Chromium screenshots"),
metric.WithDescription("Size of the generated image files."),
metric.WithUnit("By"),
)
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
@@ -707,26 +681,6 @@ func (mod *Chromium) Debug() map[string]any {
return debug
}
// Metrics returns the metrics.
func (mod *Chromium) Metrics() ([]gotenberg.Metric, error) {
return []gotenberg.Metric{
{
Name: "chromium_requests_queue_size",
Description: "Current number of Chromium conversion requests waiting to be treated.",
Read: func() float64 {
return float64(mod.supervisor.ReqQueueSize())
},
},
{
Name: "chromium_restarts_count",
Description: "Current number of Chromium restarts.",
Read: func() float64 {
return float64(mod.supervisor.RestartsCount())
},
},
}, nil
}
// Checks adds a health check that verifies if Chromium is healthy.
func (mod *Chromium) Checks() ([]health.CheckerOption, error) {
return []health.CheckerOption{
@@ -795,10 +749,9 @@ func (mod *Chromium) Routes() ([]api.Route, error) {
// Pdf converts a URL to PDF.
func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Pdf",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)),
)
// Note: no error wrapping because it leaks on errors we want to display to
// the end user.
ctx, span := gotenberg.Tracer().Start(ctx, "Chromium.Pdf")
defer span.End()
start := time.Now()
@@ -819,15 +772,17 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
status = "error"
}
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
reason := "unknown"
switch {
case errors.Is(err, context.DeadlineExceeded):
if errors.Is(err, context.DeadlineExceeded) {
reason = "timeout"
case errors.Is(err, context.Canceled):
} else if errors.Is(err, context.Canceled) {
reason = "context_cancelled"
case errors.Is(err, ErrInvalidHttpStatusCode) || errors.Is(err, ErrInvalidResourceHttpStatusCode) || errors.Is(err, ErrLoadingFailed) || errors.Is(err, ErrResourceLoadingFailed) || errors.Is(err, ErrInvalidEvaluationExpression) || errors.Is(err, ErrInvalidSelectorQuery):
} 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"
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"
}
@@ -861,21 +816,15 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
mod.pdfOutputSizeCounter.Record(ctx, fileInfo.Size())
}
span.SetStatus(codes.Ok, "")
return nil
}
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Screenshot",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)),
)
// Note: no error wrapping because it leaks on errors we want to display to
// the end user.
ctx, span := gotenberg.Tracer().Start(ctx, "Chromium.Screenshot")
defer span.End()
start := time.Now()
@@ -896,17 +845,19 @@ func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, o
status = "error"
}
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
reason := "unknown"
switch {
case errors.Is(err, context.DeadlineExceeded):
if errors.Is(err, context.DeadlineExceeded) {
reason = "timeout"
case errors.Is(err, context.Canceled):
} else if errors.Is(err, context.Canceled) {
reason = "context_cancelled"
case errors.Is(err, ErrInvalidHttpStatusCode) || errors.Is(err, ErrInvalidResourceHttpStatusCode) || errors.Is(err, ErrLoadingFailed) || errors.Is(err, ErrResourceLoadingFailed) || errors.Is(err, ErrInvalidEvaluationExpression) || errors.Is(err, ErrInvalidSelectorQuery):
} 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"
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded):
} else if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
reason = "chromium_maximum_queue_size_exceeded"
case errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
} else if errors.Is(err, gotenberg.ErrProcessAlreadyRestarting) {
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 {
mod.imageOutputSizeCounter.Record(ctx, fileInfo.Size())
}
span.SetStatus(codes.Ok, "")
return nil
}
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Interface guards.
var (
_ gotenberg.Module = (*Chromium)(nil)
_ gotenberg.Provisioner = (*Chromium)(nil)
_ gotenberg.Validator = (*Chromium)(nil)
_ gotenberg.App = (*Chromium)(nil)
_ gotenberg.Debuggable = (*Chromium)(nil)
_ gotenberg.MetricsProvider = (*Chromium)(nil)
_ api.HealthChecker = (*Chromium)(nil)
_ api.Router = (*Chromium)(nil)
_ Api = (*Chromium)(nil)
_ Provider = (*Chromium)(nil)
_ gotenberg.Module = (*Chromium)(nil)
_ gotenberg.Provisioner = (*Chromium)(nil)
_ gotenberg.Validator = (*Chromium)(nil)
_ gotenberg.App = (*Chromium)(nil)
_ gotenberg.Debuggable = (*Chromium)(nil)
_ api.HealthChecker = (*Chromium)(nil)
_ api.Router = (*Chromium)(nil)
_ Api = (*Chromium)(nil)
_ Provider = (*Chromium)(nil)
)

View File

@@ -10,19 +10,20 @@ import (
// debugLogger is wrapper around a [slog.Logger] which is used for debugging
// Chromium.
type debugLogger struct {
ctx context.Context
logger *slog.Logger
}
// Write logs the bytes in a debug message.
func (debug *debugLogger) Write(p []byte) (n int, err error) {
debug.logger.DebugContext(context.Background(), string(p))
debug.logger.DebugContext(debug.ctx, string(p))
return len(p), nil
}
// Printf logs a debug message.
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.

View File

@@ -1,3 +1,4 @@
// Package chromium adds routes for converting HTML documents to PDF. Exposes
// an [Api] for other modules.
// Package chromium provides a module which adds routes for converting HTML
// documents to PDF. Other modules may also retrieve the [Api] provided by this
// module.
package chromium

View File

@@ -2,7 +2,6 @@ package chromium
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
@@ -18,16 +17,14 @@ import (
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
"github.com/dlclark/regexp2"
"go.uber.org/multierr"
"golang.org/x/sync/errgroup"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
type eventRequestPausedOptions struct {
allowList, denyList []*regexp2.Regexp
denyPrivateIPs bool
denyPublicIPs bool
allowedFilePrefixes []string
allowList, denyList *regexp2.Regexp
extraHttpHeaders []ExtraHttpHeader
}
@@ -43,7 +40,8 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
}
chromedp.ListenTarget(ctx, func(ev any) {
if e, ok := ev.(*fetch.EventRequestPaused); ok {
switch e := ev.(type) {
case *fetch.EventRequestPaused:
go func() {
logger.DebugContext(ctx, fmt.Sprintf("event EventRequestPaused fired for '%s'", e.Request.URL))
allow := true
@@ -54,42 +52,16 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
return
}
err := gotenberg.FilterOutboundURL(ctx, e.Request.URL, options.allowList, options.denyList, deadline,
gotenberg.WithDenyPrivateIPs(options.denyPrivateIPs),
gotenberg.WithDenyPublicIPs(options.denyPublicIPs),
)
err := gotenberg.FilterDeadline(options.allowList, options.denyList, e.Request.URL, deadline)
if err != nil {
logger.WarnContext(ctx, err.Error())
allow = false
}
// Sub-resource file:// URLs are opt-in per route. A route
// that renders local files (HTML, Markdown) populates
// allowedFilePrefixes with the request working directory
// so its own assets load while sibling requests' /tmp
// paths stay out of reach. Every other route leaves the
// slice empty; treat that as default-deny so a file://
// sub-resource that slips past the deny-list (which
// exempts /tmp/) still cannot read the working
// directories of other in-flight conversions.
if allow && strings.HasPrefix(e.Request.URL, "file://") && !isAllowedFileSubResource(e.Request.URL, options.allowedFilePrefixes) {
logger.WarnContext(ctx, fmt.Sprintf("'%s' is not within any allowed file prefix", e.Request.URL))
allow = false
}
cctx := chromedp.FromContext(ctx)
executorCtx := cdp.WithExecutor(ctx, cctx.Target)
if !allow {
// Use AccessDenied so Chromium emits net::ERR_ACCESS_DENIED,
// which is intentionally absent from the EventLoadingFailed
// known-errors list. Routing through BlockedByClient would
// surface the failure, but the Document-type dispatcher in
// listenForEventLoadingFailed cannot distinguish a blocked
// iframe (sub-frame Document) from a main-page Document, and
// would attribute the iframe failure to the main page.
// Filter-block observability is provided by the warn log
// above instead.
req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
err = req.Do(executorCtx)
if err != nil {
@@ -115,13 +87,12 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
}
ok, err := header.Scope.MatchString(e.Request.URL)
switch {
case err != nil:
if 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))
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))
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))
}
}
@@ -177,7 +148,6 @@ type eventResponseReceivedOptions struct {
ignoreResourceHttpStatusDomains []string
invalidResourceHttpStatusCode *error
invalidResourceHttpStatusCodeMu *sync.RWMutex
cancelOnMainPageError context.CancelFunc
}
// listenForEventResponseReceived listens for an invalid HTTP status code
@@ -207,7 +177,8 @@ func listenForEventResponseReceived(
}
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 {
logger.DebugContext(ctx, fmt.Sprintf("event EventResponseReceived fired for main page: %+v", ev.Response))
@@ -216,14 +187,6 @@ func listenForEventResponseReceived(
defer options.invalidHttpStatusCodeMu.Unlock()
*options.invalidHttpStatusCode = fmt.Errorf("%d: %s", ev.Response.Status, ev.Response.StatusText)
// Cancel the task context so that any in-flight wait
// operations (waitForSelector, waitForExpression, etc.)
// abort immediately instead of polling until timeout.
// See https://github.com/gotenberg/gotenberg/issues/1492.
if options.cancelOnMainPageError != nil {
options.cancelOnMainPageError()
}
}
return
@@ -240,7 +203,7 @@ func listenForEventResponseReceived(
options.invalidResourceHttpStatusCodeMu.Lock()
defer options.invalidResourceHttpStatusCodeMu.Unlock()
*options.invalidResourceHttpStatusCode = errors.Join(
*options.invalidResourceHttpStatusCode = multierr.Append(
*options.invalidResourceHttpStatusCode,
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 {
host := hostnameFromURL(rawURL)
@@ -344,7 +290,6 @@ type eventLoadingFailedOptions struct {
loadingFailedMu *sync.RWMutex
resourceLoadingFailed *error
resourceLoadingFailedMu *sync.RWMutex
cancelOnMainPageError context.CancelFunc
}
// listenForEventLoadingFailed listens for an event indicating that the main
@@ -355,12 +300,13 @@ type eventLoadingFailedOptions struct {
// https://github.com/gotenberg/gotenberg/issues/1021.
func listenForEventLoadingFailed(ctx context.Context, logger *slog.Logger, options eventLoadingFailedOptions) {
chromedp.ListenTarget(ctx, func(ev any) {
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))
// We are looking for common errors.
// TODO: sufficient?
knownErrors := []string{
errors := []string{
"net::ERR_CONNECTION_CLOSED",
"net::ERR_CONNECTION_RESET",
"net::ERR_CONNECTION_REFUSED",
@@ -374,8 +320,8 @@ func listenForEventLoadingFailed(ctx context.Context, logger *slog.Logger, optio
"net::ERR_FILE_NOT_FOUND",
"net::ERR_HTTP2_PROTOCOL_ERROR",
}
if !slices.Contains(knownErrors, ev.ErrorText) {
logger.DebugContext(ctx, fmt.Sprintf("skip EventLoadingFailed: '%s' is not part of %+v", ev.ErrorText, knownErrors))
if !slices.Contains(errors, ev.ErrorText) {
logger.DebugContext(ctx, fmt.Sprintf("skip EventLoadingFailed: '%s' is not part of %+v", ev.ErrorText, errors))
return
}
@@ -389,13 +335,6 @@ func listenForEventLoadingFailed(ctx context.Context, logger *slog.Logger, optio
*options.loadingFailed = fmt.Errorf("%s", ev.ErrorText)
// Cancel the task context so that any in-flight wait
// operations abort immediately.
// See https://github.com/gotenberg/gotenberg/issues/1492.
if options.cancelOnMainPageError != nil {
options.cancelOnMainPageError()
}
return
}
@@ -404,7 +343,7 @@ func listenForEventLoadingFailed(ctx context.Context, logger *slog.Logger, optio
options.resourceLoadingFailedMu.Lock()
defer options.resourceLoadingFailedMu.Unlock()
*options.resourceLoadingFailed = errors.Join(
*options.resourceLoadingFailed = multierr.Append(
*options.resourceLoadingFailed,
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.
func listenForEventExceptionThrown(ctx context.Context, logger *slog.Logger, consoleExceptions *error, consoleExceptionsMu *sync.RWMutex) {
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))
consoleExceptionsMu.Lock()
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{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if _, ok := ev.(*page.EventDomContentEventFired); ok {
switch ev.(type) {
case *page.EventDomContentEventFired:
cancel()
close(ch)
}
@@ -458,7 +399,8 @@ func waitForEventLoadEventFired(ctx context.Context, logger *slog.Logger) func()
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if _, ok := ev.(*page.EventLoadEventFired); ok {
switch ev.(type) {
case *page.EventLoadEventFired:
cancel()
close(ch)
}
@@ -481,9 +423,12 @@ func waitForEventNetworkIdle(ctx context.Context, logger *slog.Logger) func() er
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle" {
cancel()
close(ch)
switch e := ev.(type) {
case *page.EventLifecycleEvent:
if e.Name == "networkIdle" {
cancel()
close(ch)
}
}
})
@@ -504,9 +449,12 @@ func waitForEventNetworkAlmostIdle(ctx context.Context, logger *slog.Logger) fun
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle2" {
cancel()
close(ch)
switch e := ev.(type) {
case *page.EventLifecycleEvent:
if e.Name == "networkIdle2" {
cancel()
close(ch)
}
}
})
@@ -527,7 +475,8 @@ func waitForEventLoadingFinished(ctx context.Context, logger *slog.Logger) func(
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if _, ok := ev.(*network.EventLoadingFinished); ok {
switch ev.(type) {
case *network.EventLoadingFinished:
cancel()
close(ch)
}

View File

@@ -61,49 +61,3 @@ func TestShouldCheckResourceHttpStatusCode_NonHTTPURL(t *testing.T) {
t.Fatalf("expected data: URL to be checked (no host filtering possible)")
}
}
func TestIsAllowedFileSubResource(t *testing.T) {
for _, tc := range []struct {
name string
rawURL string
prefixes []string
want bool
}{
{
name: "empty prefix list default denies",
rawURL: "file:///tmp/work-uuid/request-uuid/index.html",
prefixes: nil,
want: false,
},
{
name: "match within the sole prefix",
rawURL: "file:///tmp/work-uuid/request-uuid/index.html",
prefixes: []string{"/tmp/work-uuid/request-uuid"},
want: true,
},
{
name: "sibling request directory rejected",
rawURL: "file:///tmp/work-uuid/other-request-uuid/secret.html",
prefixes: []string{"/tmp/work-uuid/request-uuid"},
want: false,
},
{
name: "parent tmp directory rejected",
rawURL: "file:///tmp/",
prefixes: []string{"/tmp/work-uuid/request-uuid"},
want: false,
},
{
name: "match among several prefixes",
rawURL: "file:///tmp/work-uuid/request-b/asset.css",
prefixes: []string{"/tmp/work-uuid/request-a", "/tmp/work-uuid/request-b"},
want: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
if got := isAllowedFileSubResource(tc.rawURL, tc.prefixes); got != tc.want {
t.Fatalf("isAllowedFileSubResource(%q, %v) = %v, want %v", tc.rawURL, tc.prefixes, got, tc.want)
}
})
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -7,7 +7,6 @@ import (
"fmt"
"html/template"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
@@ -18,6 +17,7 @@ import (
"github.com/gomarkdown/markdown"
"github.com/labstack/echo/v4"
"github.com/microcosm-cc/bluemonday"
"go.uber.org/multierr"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
@@ -150,7 +150,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
for i, cookie := range cookies {
if strings.TrimSpace(cookie.Name) == "" || strings.TrimSpace(cookie.Value) == "" || strings.TrimSpace(cookie.Domain) == "" {
err = 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] != "" {
scope = parts[1]
} 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
break
}
} else if token != "" {
valueTokens = append(valueTokens, token)
} else {
if token != "" {
valueTokens = append(valueTokens, token)
}
}
}
@@ -200,10 +202,9 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
if len(scope) > 0 {
p, errCompile := regexp2.Compile(scope, regexp2.None)
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
}
p.MatchTimeout = 5 * time.Second
scopeRegexp = p
}
@@ -403,34 +404,6 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
return form, screenshotOptions
}
// rejectFileScheme returns an HTTP 400 [api] error when rawURL uses the
// file:// scheme. /forms/chromium/convert/url and
// /forms/chromium/screenshot/url accept user-supplied URLs and are
// intended for navigating to remote HTTP(S) resources; allowing file://
// lets a caller reach Chromium's working directory through the default
// deny-list's /tmp/ allowance, which exists only to serve main-page
// HTML/Markdown that the other routes generate. Filter the scheme at the
// route layer where no request-scoped allowedFilePrefixes exists.
func rejectFileScheme(rawURL string) error {
parsed, err := url.Parse(rawURL)
if err != nil {
return api.WrapError(
fmt.Errorf("parse URL: %w", err),
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid URL: %s", err)),
)
}
if strings.EqualFold(parsed.Scheme, "file") {
return api.WrapError(
fmt.Errorf("file:// scheme not allowed on URL route"),
api.NewSentinelHttpError(
http.StatusBadRequest,
"file:// URLs are not accepted on this route. Use the /convert/html or /convert/markdown routes to render local HTML",
),
)
}
return nil
}
// convertUrlRoute returns an [api.Route] which can convert a URL to PDF.
func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return api.Route{
@@ -444,13 +417,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
attachmentsPaths := pdfengines.FormDataPdfAttachments(form)
var url string
err := form.
@@ -460,21 +427,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
err = rejectFileScheme(url)
if err != nil {
return fmt.Errorf("reject URL scheme: %w", err)
}
err = 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)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, attachmentsPaths)
if err != nil {
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)
}
err = rejectFileScheme(url)
if err != nil {
return fmt.Errorf("reject URL scheme: %w", err)
}
err = screenshotUrl(ctx, chromium, url, options)
if err != nil {
return fmt.Errorf("URL screenshot: %w", err)
@@ -532,13 +480,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
attachmentsPaths := pdfengines.FormDataPdfAttachments(form)
var inputPath string
err := form.
@@ -548,18 +490,8 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
if err != nil {
return fmt.Errorf("validate watermark: %w", err)
}
err = pdfengines.EnsureStampFile(&stamp, stampFile)
if err != nil {
return fmt.Errorf("validate stamp: %w", err)
}
url := fmt.Sprintf("file://%s", inputPath)
options.AllowedFilePrefixes = []string{ctx.DirPath()}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, attachmentsPaths)
if err != nil {
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)
options.AllowedFilePrefixes = []string{ctx.DirPath()}
err = screenshotUrl(ctx, chromium, url, options)
if err != nil {
return fmt.Errorf("HTML screenshot: %w", err)
@@ -614,13 +545,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
attachmentsPaths := pdfengines.FormDataPdfAttachments(form)
var (
inputPath string
@@ -635,22 +560,12 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
if err != nil {
return fmt.Errorf("validate watermark: %w", err)
}
err = pdfengines.EnsureStampFile(&stamp, stampFile)
if err != nil {
return fmt.Errorf("validate stamp: %w", err)
}
url, err := markdownToHtml(ctx, inputPath, markdownPaths)
if err != nil {
return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
}
options.AllowedFilePrefixes = []string{ctx.DirPath()}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, attachmentsPaths)
if err != nil {
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)
}
options.AllowedFilePrefixes = []string{ctx.DirPath()}
err = screenshotUrl(ctx, chromium, url, options)
if err != nil {
return fmt.Errorf("markdown screenshot: %w", err)
@@ -715,7 +629,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
var path string
for _, markdownPath := range markdownPaths {
markdownFilename := ctx.OriginalFilename(markdownPath)
markdownFilename := filepath.Base(markdownPath)
if filename == markdownFilename {
path = markdownPath
@@ -724,7 +638,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
}
if path == "" {
markdownFilesNotFoundErr = errors.Join(
markdownFilesNotFoundErr = multierr.Append(
markdownFilesNotFoundErr,
fmt.Errorf("'%s'", filename),
)
@@ -775,7 +689,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
return fmt.Sprintf("file://%s", inputPath), nil
}
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, embedPaths []string, embedsMetadata map[string]map[string]string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error {
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, attachmentsPaths []string) error {
outputPath := ctx.GeneratePath(".pdf")
// See https://github.com/gotenberg/gotenberg/issues/1130.
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)
}
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
if err != nil {
return err
}
outputPaths, err := pdfengines.SplitPdfStub(ctx, engine, mode, []string{outputPath})
if err != nil {
return fmt.Errorf("split PDF: %w", err)
}
err = pdfengines.WatermarkStub(ctx, engine, watermark, outputPaths)
if err != nil {
return fmt.Errorf("watermark PDFs: %w", err)
}
err = pdfengines.StampStub(ctx, engine, stamp, outputPaths)
if err != nil {
return fmt.Errorf("stamp PDFs: %w", err)
}
err = pdfengines.RotateStub(ctx, engine, rotateAngle, rotatePages, outputPaths)
if err != nil {
return fmt.Errorf("rotate PDFs: %w", err)
}
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil {
return fmt.Errorf("convert PDF(s): %w", err)
}
// Metadata, embeds are written after Convert, as LibreOffice
// strips them during PDF/A conversion.
err = pdfengines.AddAttachmentsStub(ctx, engine, attachmentsPaths, convertOutputPaths)
if err != nil {
return fmt.Errorf("add attachments into PDFs: %w", err)
}
err = pdfengines.WriteMetadataStub(ctx, engine, metadata, convertOutputPaths)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
}
err = pdfengines.EmbedFilesStub(ctx, engine, embedPaths, convertOutputPaths)
if err != nil {
return fmt.Errorf("embed files into PDFs: %w", err)
}
err = pdfengines.EmbedFilesMetadataStub(ctx, engine, embedsMetadata, convertOutputPaths)
if err != nil {
return fmt.Errorf("set embeds metadata: %w", err)
}
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err)

View File

@@ -31,9 +31,7 @@ func printToPdfActionFunc(logger *slog.Logger, outputPath string, options PdfOpt
// There are 96 CSS pixels per inch.
// See https://issues.chromium.org/issues/40267771#comment14.
// We add top and bottom margins so that the content area
// is large enough to fit the entire content.
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
paperHeight = cssContentSize.Height / 96
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.
// See https://github.com/gotenberg/gotenberg/issues/1011.
//
// func extraHttpHeadersActionFunc(logger *slog.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
// return func(ctx context.Context) error {
// if len(extraHttpHeaders) == 0 {
// logger.DebugContext(ctx,"no extra HTTP headers")
// return nil
// }
//func extraHttpHeadersActionFunc(logger *slog.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
// return func(ctx context.Context) error {
// if len(extraHttpHeaders) == 0 {
// logger.DebugContext(ctx, "no extra HTTP headers")
// return nil
// }
//
// logger.DebugContext(ctx,fmt.Sprintf("extra HTTP headers: %+v", extraHttpHeaders))
// logger.DebugContext(ctx, fmt.Sprintf("extra HTTP headers: %+v", extraHttpHeaders))
//
// headers := make(network.Headers, len(extraHttpHeaders))
// for key, value := range extraHttpHeaders {
// headers[key] = value
// }
// headers := make(network.Headers, len(extraHttpHeaders))
// for key, value := range extraHttpHeaders {
// headers[key] = value
// }
//
// err := network.SetExtraHTTPHeaders(headers).Do(ctx)
// if err == nil {
// return nil
// }
// err := network.SetExtraHTTPHeaders(headers).Do(ctx)
// if err == nil {
// return nil
// }
//
// return fmt.Errorf("set extra HTTP headers: %w", err)
// }
// }
// return fmt.Errorf("set extra HTTP headers: %w", err)
// }
//}
func navigateActionFunc(logger *slog.Logger, url string, skipNetworkIdleEvent, skipNetworkAlmostIdleEvent bool) chromedp.ActionFunc {
return func(ctx context.Context) error {

View File

@@ -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

View File

@@ -2,18 +2,17 @@ package exiftool
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"regexp"
"reflect"
"strings"
"syscall"
"github.com/barasher/go-exiftool"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
@@ -23,138 +22,6 @@ func init() {
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
// [gotenberg.PdfEngine] interface.
type ExifTool struct {
@@ -210,319 +77,225 @@ func (engine *ExifTool) Debug() map[string]any {
// Merge is not available in this implementation.
func (engine *ExifTool) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Merge",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
_, span := gotenberg.Tracer().Start(ctx, "ExifTool.Merge", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
err := fmt.Errorf("merge PDFs with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
return err
}
// 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) {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Split",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
_, span := gotenberg.Tracer().Start(ctx, "ExifTool.Split", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
err := fmt.Errorf("split PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
return nil, err
}
// Flatten is not available in this implementation.
func (engine *ExifTool) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Flatten",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
_, span := gotenberg.Tracer().Start(ctx, "ExifTool.Flatten", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
err := fmt.Errorf("flatten PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
return err
}
// Convert is not available in this implementation.
func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Convert",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
_, span := gotenberg.Tracer().Start(ctx, "ExifTool.Convert", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
err := fmt.Errorf("convert PDF to '%+v' with ExifTool: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
return err
}
// ReadMetadata extracts the metadata of a given PDF file by invoking
// the exiftool binary with "-j" (JSON output) and parsing the result.
// ReadMetadata extracts the metadata of a given PDF file.
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.ReadMetadata",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
_, span := gotenberg.Tracer().Start(ctx, "ExifTool.ReadMetadata", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
cmd := exec.CommandContext(ctx, engine.binPath, "-j", inputPath) //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
output, err := cmd.Output()
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
if err != nil {
err = fmt.Errorf("read metadata with ExifTool: %w", err)
err = fmt.Errorf("new ExifTool: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
var files []map[string]any
err = json.Unmarshal(output, &files)
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)
defer func(exifTool *exiftool.Exiftool) {
err := exifTool.Close()
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.SetStatus(codes.Error, err.Error())
return 0, err
return err
}
span.SetStatus(codes.Ok, "")
return res, nil
default:
err = fmt.Errorf("unexpected PageCount type '%T'", pageCountValue)
}
exifTool.WriteMetadata(fileMetadata)
if fileMetadata[0].Err != nil {
err = fmt.Errorf("write PDF metadata with ExifTool: %w", fileMetadata[0].Err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return 0, err
return err
}
}
// WriteBookmarks is not available in this implementation.
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
return nil
}
// Encrypt is not available in this implementation.
func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Encrypt",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
_, span := gotenberg.Tracer().Start(ctx, "ExifTool.Encrypt", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
err := fmt.Errorf("encrypt PDF using ExifTool: %w", gotenberg.ErrPdfEncryptionNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
return err
}
// EmbedFiles is not available in this implementation.
func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.EmbedFiles",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
// AddAttachments is not available in this implementation.
func (engine *ExifTool) AddAttachments(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "ExifTool.AddAttachments", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
err := fmt.Errorf("embed files with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
err := fmt.Errorf("add attachments with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
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.
var (
_ gotenberg.Module = (*ExifTool)(nil)

Some files were not shown because too many files have changed in this diff Show More