mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 08:32:16 +01:00
Compare commits
156 Commits
v8.29.0
...
8b559eb699
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b559eb699 | ||
|
|
50e8e44bc1 | ||
|
|
b243283c9b | ||
|
|
a0d5e93ced | ||
|
|
7568fc379b | ||
|
|
5b21dbf1a9 | ||
|
|
0a62359691 | ||
|
|
7418b5cab3 | ||
|
|
337a5cca64 | ||
|
|
387fce2cf6 | ||
|
|
c89991cc0d | ||
|
|
0c8d681c35 | ||
|
|
3d300c2a09 | ||
|
|
8aa5a4f0e7 | ||
|
|
a92a7fedba | ||
|
|
d0e3991d16 | ||
|
|
d67ef724f9 | ||
|
|
68a0a9e5c1 | ||
|
|
b5f77c3b73 | ||
|
|
be5dcf943d | ||
|
|
9ca1e302bd | ||
|
|
3617bc6587 | ||
|
|
874adf076c | ||
|
|
9d83255ecd | ||
|
|
3b43bfbf33 | ||
|
|
7614964109 | ||
|
|
7b054da4e7 | ||
|
|
98fc403478 | ||
|
|
808a96f3d0 | ||
|
|
d4c20c6b39 | ||
|
|
2050b4ae6b | ||
|
|
c0ed2dee3c | ||
|
|
f8905bac8c | ||
|
|
3b1e4cbac4 | ||
|
|
287ee5be72 | ||
|
|
9ab39b6fca | ||
|
|
5558e43821 | ||
|
|
40666529f9 | ||
|
|
0f5e1b794a | ||
|
|
3ab8c5920b | ||
|
|
54853e2cbd | ||
|
|
5c6a5c64b5 | ||
|
|
60482a5cdf | ||
|
|
a6faa892a8 | ||
|
|
4ebd977d97 | ||
|
|
a82dd9f031 | ||
|
|
8668a1d710 | ||
|
|
e7c8a6a50c | ||
|
|
11ab93aef6 | ||
|
|
1498473495 | ||
|
|
c24883b8a1 | ||
|
|
f6069ef84f | ||
|
|
7465166de7 | ||
|
|
de572fe6b5 | ||
|
|
2b20399a80 | ||
|
|
7c630ecc6e | ||
|
|
bbebad1175 | ||
|
|
387728f09a | ||
|
|
65e8bf20d4 | ||
|
|
e1c28d8450 | ||
|
|
c63dd5ce1e | ||
|
|
7a439632da | ||
|
|
525102b991 | ||
|
|
8e1ab0110f | ||
|
|
a5efccd4cc | ||
|
|
0b0e817ca5 | ||
|
|
f5b26c0b2c | ||
|
|
8003109012 | ||
|
|
a0ec46a764 | ||
|
|
b32545e589 | ||
|
|
cb461bb1fe | ||
|
|
190cad0ee2 | ||
|
|
1d0c9acb2a | ||
|
|
08181f8550 | ||
|
|
9ea0e82525 | ||
|
|
7967035981 | ||
|
|
3d891edee4 | ||
|
|
320ad62e7b | ||
|
|
27e70fde46 | ||
|
|
2a9bf6bf11 | ||
|
|
7f9c3e171c | ||
|
|
13c5b99962 | ||
|
|
6671b5e5d3 | ||
|
|
f9a01c9fb3 | ||
|
|
26d373854a | ||
|
|
93d0103585 | ||
|
|
c1cdcbdaab | ||
|
|
fe1b0020b8 | ||
|
|
b47b9f45d8 | ||
|
|
8c0ad887f2 | ||
|
|
45b9f32351 | ||
|
|
ace379a92c | ||
|
|
4998870723 | ||
|
|
64c28dd45e | ||
|
|
68e0f88d5b | ||
|
|
cc97cb7e59 | ||
|
|
200334197f | ||
|
|
ce9c48b2b0 | ||
|
|
05465b3a74 | ||
|
|
1c0ff24c4b | ||
|
|
8f711b0f99 | ||
|
|
430f95f79f | ||
|
|
259d80bb68 | ||
|
|
7a914fce65 | ||
|
|
a2a8c42457 | ||
|
|
4b192b1498 | ||
|
|
c204cadfc5 | ||
|
|
35f1a990a6 | ||
|
|
7729bd0590 | ||
|
|
2980ca97a3 | ||
|
|
bb95b9b7f3 | ||
|
|
62d98fef79 | ||
|
|
db51f9026d | ||
|
|
3187980ead | ||
|
|
eff9444294 | ||
|
|
999dd2db40 | ||
|
|
f44d59d514 | ||
|
|
092b526ab5 | ||
|
|
0b5eaff8d9 | ||
|
|
46e190970f | ||
|
|
55d19522a8 | ||
|
|
924576d3d4 | ||
|
|
405d8d1c2b | ||
|
|
1b1e100107 | ||
|
|
c3b4702424 | ||
|
|
866ae53436 | ||
|
|
53e6a0ecf8 | ||
|
|
eab3d2da12 | ||
|
|
3f01ca18d3 | ||
|
|
405f1069c0 | ||
|
|
f1d1215c7c | ||
|
|
9bf12e7cb9 | ||
|
|
b87a6d4dfb | ||
|
|
a80191a288 | ||
|
|
e4a43434dc | ||
|
|
4811a00543 | ||
|
|
cfb48d9af4 | ||
|
|
8b65315a09 | ||
|
|
20522fd1ac | ||
|
|
e28e2ebf0f | ||
|
|
15050a311b | ||
|
|
c99eb471f9 | ||
|
|
53c2f97008 | ||
|
|
61cadd0e6a | ||
|
|
813926cd47 | ||
|
|
f447e31ae2 | ||
|
|
00c6ed0387 | ||
|
|
cdf861bcfd | ||
|
|
5279d73928 | ||
|
|
e6d2a5640b | ||
|
|
4387a4f122 | ||
|
|
bf0d0a4f40 | ||
|
|
f8809b3943 | ||
|
|
9e5b05ad3c | ||
|
|
452a500955 | ||
|
|
1ab0117943 |
@@ -1,65 +0,0 @@
|
||||
# Bruno API Collection
|
||||
|
||||
A [Bruno](https://www.usebruno.com/) collection lives in `.bruno/` and mirrors every Gotenberg route. When adding or updating a route, update the collection to match.
|
||||
|
||||
## 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** are listed without prefix; **optional fields** are prefixed with `~` (disabled by default in Bruno).
|
||||
- **File references** use relative paths to `test/integration/testdata/`.
|
||||
- **Webhook and output filename headers** are included 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 When Adding/Updating a Route
|
||||
|
||||
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. If you add a new module folder, keep the naming consistent (e.g., `PDF Engines/Rotate/`).
|
||||
@@ -11,7 +11,7 @@ post {
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/page-1-html/index.html)
|
||||
files: @file(../test/integration/testdata/page-1-html/index.html)
|
||||
~landscape: false
|
||||
~printBackground: false
|
||||
~scale: 1.0
|
||||
@@ -50,6 +50,19 @@ body:multipart-form {
|
||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
~allowPrinting: false
|
||||
~allowCopying: false
|
||||
~allowModifying: false
|
||||
~allowAnnotating: false
|
||||
~allowFillingForms: false
|
||||
~allowAssembling: false
|
||||
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||
~embeds: @file(../test/integration/testdata/embed_2.xml)
|
||||
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
||||
~facturxXml: @file(../test/integration/testdata/embed_1.xml)
|
||||
~facturxConformanceLevel: EN 16931
|
||||
~facturxDocumentType: INVOICE
|
||||
~facturxVersion: 1.0
|
||||
~watermarkSource: text
|
||||
~watermarkExpression: CONFIDENTIAL
|
||||
~watermarkPages:
|
||||
|
||||
@@ -51,6 +51,19 @@ body:multipart-form {
|
||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
~allowPrinting: false
|
||||
~allowCopying: false
|
||||
~allowModifying: false
|
||||
~allowAnnotating: false
|
||||
~allowFillingForms: false
|
||||
~allowAssembling: false
|
||||
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||
~embeds: @file(../test/integration/testdata/embed_2.xml)
|
||||
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
||||
~facturxXml: @file(../../test/integration/testdata/embed_1.xml)
|
||||
~facturxConformanceLevel: EN 16931
|
||||
~facturxDocumentType: INVOICE
|
||||
~facturxVersion: 1.0
|
||||
~watermarkSource: text
|
||||
~watermarkExpression: CONFIDENTIAL
|
||||
~watermarkPages:
|
||||
|
||||
@@ -50,6 +50,19 @@ body:multipart-form {
|
||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
~allowPrinting: false
|
||||
~allowCopying: false
|
||||
~allowModifying: false
|
||||
~allowAnnotating: false
|
||||
~allowFillingForms: false
|
||||
~allowAssembling: false
|
||||
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||
~embeds: @file(../test/integration/testdata/embed_2.xml)
|
||||
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
||||
~facturxXml: @file(../test/integration/testdata/embed_1.xml)
|
||||
~facturxConformanceLevel: EN 16931
|
||||
~facturxDocumentType: INVOICE
|
||||
~facturxVersion: 1.0
|
||||
~watermarkSource: text
|
||||
~watermarkExpression: CONFIDENTIAL
|
||||
~watermarkPages:
|
||||
|
||||
@@ -34,6 +34,7 @@ body:multipart-form {
|
||||
~emulatedMediaType: screen
|
||||
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
|
||||
~omitBackground: false
|
||||
~deviceScaleFactor: 1.0
|
||||
}
|
||||
|
||||
headers {
|
||||
|
||||
@@ -35,6 +35,7 @@ body:multipart-form {
|
||||
~emulatedMediaType: screen
|
||||
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
|
||||
~omitBackground: false
|
||||
~deviceScaleFactor: 1.0
|
||||
}
|
||||
|
||||
headers {
|
||||
|
||||
@@ -34,6 +34,7 @@ body:multipart-form {
|
||||
~emulatedMediaType: screen
|
||||
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
|
||||
~omitBackground: false
|
||||
~deviceScaleFactor: 1.0
|
||||
}
|
||||
|
||||
headers {
|
||||
|
||||
@@ -11,7 +11,7 @@ post {
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/page_1.docx)
|
||||
files: @file(../test/integration/testdata/page_1.docx)
|
||||
~password:
|
||||
~landscape: false
|
||||
~nativePageRanges:
|
||||
@@ -67,6 +67,19 @@ body:multipart-form {
|
||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
~allowPrinting: false
|
||||
~allowCopying: false
|
||||
~allowModifying: false
|
||||
~allowAnnotating: false
|
||||
~allowFillingForms: false
|
||||
~allowAssembling: false
|
||||
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||
~embeds: @file(../test/integration/testdata/embed_2.xml)
|
||||
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
||||
~facturxXml: @file(../test/integration/testdata/embed_1.xml)
|
||||
~facturxConformanceLevel: EN 16931
|
||||
~facturxDocumentType: INVOICE
|
||||
~facturxVersion: 1.0
|
||||
~watermarkSource: text
|
||||
~watermarkExpression: CONFIDENTIAL
|
||||
~watermarkPages:
|
||||
|
||||
@@ -11,8 +11,14 @@ post {
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/page_1.pdf)
|
||||
embeds: @file(../../test/integration/testdata/page_1.pdf)
|
||||
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"}}
|
||||
~facturxXml: @file(../test/integration/testdata/embed_1.xml)
|
||||
~facturxConformanceLevel: EN 16931
|
||||
~facturxDocumentType: INVOICE
|
||||
~facturxVersion: 1.0
|
||||
~downloadFrom: [{"url":"https://example.com/attachment.xml","embedded":true}]
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,12 @@ body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/page_1.pdf)
|
||||
userPassword: secret123
|
||||
~ownerPassword: owner456
|
||||
~allowPrinting: false
|
||||
~allowCopying: false
|
||||
~allowModifying: false
|
||||
~allowAnnotating: false
|
||||
~allowFillingForms: false
|
||||
~allowAssembling: false
|
||||
}
|
||||
|
||||
headers {
|
||||
|
||||
29
.bruno/PDF Engines/Factur-X/Inject Factur-X XMP.bru
Normal file
29
.bruno/PDF Engines/Factur-X/Inject Factur-X XMP.bru
Normal file
@@ -0,0 +1,29 @@
|
||||
meta {
|
||||
name: Inject Factur-X XMP
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/pdfengines/factur-x
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/page_1.pdf)
|
||||
facturxXml: @file(../../test/integration/testdata/embed_1.xml)
|
||||
facturxConformanceLevel: EN 16931
|
||||
~facturxDocumentType: INVOICE
|
||||
~facturxVersion: 1.0
|
||||
}
|
||||
|
||||
headers {
|
||||
~Gotenberg-Output-Filename: factur-x
|
||||
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
|
||||
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
|
||||
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
|
||||
~Gotenberg-Webhook-Method: POST
|
||||
~Gotenberg-Webhook-Error-Method: POST
|
||||
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
|
||||
}
|
||||
@@ -21,6 +21,12 @@ body:multipart-form {
|
||||
~bookmarks: [{"title":"Page 1","page":1},{"title":"Page 2","page":2}]
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
~allowPrinting: false
|
||||
~allowCopying: false
|
||||
~allowModifying: false
|
||||
~allowAnnotating: false
|
||||
~allowFillingForms: false
|
||||
~allowAssembling: false
|
||||
~watermarkSource: text
|
||||
~watermarkExpression: CONFIDENTIAL
|
||||
~watermarkPages:
|
||||
@@ -31,6 +37,10 @@ body:multipart-form {
|
||||
~stampOptions: {"scale":"0.5 abs","rot":"45"}
|
||||
~rotateAngle: 90
|
||||
~rotatePages:
|
||||
~facturxXml: @file(../../test/integration/testdata/embed_1.xml)
|
||||
~facturxConformanceLevel: EN 16931
|
||||
~facturxDocumentType: INVOICE
|
||||
~facturxVersion: 1.0
|
||||
}
|
||||
|
||||
headers {
|
||||
|
||||
@@ -21,6 +21,12 @@ body:multipart-form {
|
||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
~allowPrinting: false
|
||||
~allowCopying: false
|
||||
~allowModifying: false
|
||||
~allowAnnotating: false
|
||||
~allowFillingForms: false
|
||||
~allowAssembling: false
|
||||
~watermarkSource: text
|
||||
~watermarkExpression: CONFIDENTIAL
|
||||
~watermarkPages:
|
||||
@@ -31,6 +37,10 @@ body:multipart-form {
|
||||
~stampOptions: {"scale":"0.5 abs","rot":"45"}
|
||||
~rotateAngle: 90
|
||||
~rotatePages:
|
||||
~facturxXml: @file(../../test/integration/testdata/embed_1.xml)
|
||||
~facturxConformanceLevel: EN 16931
|
||||
~facturxDocumentType: INVOICE
|
||||
~facturxVersion: 1.0
|
||||
}
|
||||
|
||||
headers {
|
||||
|
||||
65
.bruno/README.md
Normal file
65
.bruno/README.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Bruno API Collection
|
||||
|
||||
[Bruno](https://www.usebruno.com/) collection mirroring every Gotenberg route. Update the collection when adding or modifying a route.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
.bruno/
|
||||
├── bruno.json # Collection config
|
||||
├── collection.bru # Collection-level defaults (Gotenberg-Trace header)
|
||||
├── environments/
|
||||
│ ├── Local.bru # baseUrl: http://localhost:3000
|
||||
│ └── Demo.bru # baseUrl: https://demo.gotenberg.dev
|
||||
├── Health & Info/ # GET routes
|
||||
├── Chromium/Convert/ # POST routes grouped by module
|
||||
├── Chromium/Screenshot/
|
||||
├── LibreOffice/
|
||||
└── PDF Engines/<Feature>/ # One folder per feature (Merge, Split, Rotate, ...)
|
||||
```
|
||||
|
||||
## `.bru` file format
|
||||
|
||||
```bru
|
||||
meta {
|
||||
name: <Human-readable name>
|
||||
type: http
|
||||
seq: <order within folder>
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/<path>
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/<file>)
|
||||
<mandatoryField>: <value>
|
||||
~<optionalField>: <value>
|
||||
}
|
||||
|
||||
headers {
|
||||
~Gotenberg-Output-Filename: <name>
|
||||
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
|
||||
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
|
||||
~Gotenberg-Webhook-Method: POST
|
||||
~Gotenberg-Webhook-Error-Method: POST
|
||||
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
|
||||
}
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- Mandatory fields have no prefix. Optional fields use `~` (disabled by default in Bruno).
|
||||
- File references use relative paths to `test/integration/testdata/`.
|
||||
- Webhook and output filename headers appear on every POST route as optional (`~`).
|
||||
- One `.bru` file per request. For routes with read/write variants (e.g., bookmarks, metadata), create separate files in the same folder.
|
||||
|
||||
## Checklist
|
||||
|
||||
1. Create or update the `.bru` file in the matching folder under `.bruno/`.
|
||||
2. Include all form fields from the route handler. Check `FormData*` calls in the route function.
|
||||
3. For file upload fields (`files`, `watermark`, `stamp`, `embeds`), use `@file(...)` with a suitable test file.
|
||||
4. Verify the URL path matches the route's `Path` field exactly.
|
||||
5. For new module folders, keep the naming consistent (e.g., `PDF Engines/Rotate/`).
|
||||
3
.env
3
.env
@@ -2,6 +2,5 @@ GOTENBERG_VERSION=snapshot
|
||||
DOCKER_REGISTRY=gotenberg
|
||||
DOCKER_REPOSITORY=gotenberg
|
||||
DOCKERFILE=build/Dockerfile
|
||||
DOCKERFILE_CLOUDRUN=build/Dockerfile.cloudrun
|
||||
DOCKERFILE_AWS_LAMBDA=build/Dockerfile.aws-lambda
|
||||
DOCKER_BUILD_CONTEXT='.'
|
||||
TARGET=gotenberg
|
||||
|
||||
32
.github/actions/build-test-push/action.yml
vendored
32
.github/actions/build-test-push/action.yml
vendored
@@ -29,14 +29,32 @@ inputs:
|
||||
|
||||
outputs:
|
||||
tags:
|
||||
description: Comma separated list of tag
|
||||
description: Comma separated list of full variant tags
|
||||
value: ${{ steps.build.outputs.tags }}
|
||||
tags_chromium:
|
||||
description: Comma separated list of Chromium variant tags
|
||||
value: ${{ steps.build.outputs.tags_chromium }}
|
||||
tags_libreoffice:
|
||||
description: Comma separated list of LibreOffice variant tags
|
||||
value: ${{ steps.build.outputs.tags_libreoffice }}
|
||||
tags_cloud_run:
|
||||
description: Comma separated list of Cloud Run tags (linux/amd64 only)
|
||||
description: Comma separated list of Cloud Run full variant tags (linux/amd64 only)
|
||||
value: ${{ steps.build.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium:
|
||||
description: Comma separated list of Cloud Run Chromium variant tags (linux/amd64 only)
|
||||
value: ${{ steps.build.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice:
|
||||
description: Comma separated list of Cloud Run LibreOffice variant tags (linux/amd64 only)
|
||||
value: ${{ steps.build.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda:
|
||||
description: Comma separated list of AWS Lambda tags (linux/amd64 and linux/arm64 only)
|
||||
description: Comma separated list of AWS Lambda full variant tags (linux/amd64 and linux/arm64 only)
|
||||
value: ${{ steps.build.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium:
|
||||
description: Comma separated list of AWS Lambda Chromium variant tags (linux/amd64 and linux/arm64 only)
|
||||
value: ${{ steps.build.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice:
|
||||
description: Comma separated list of AWS Lambda LibreOffice variant tags (linux/amd64 and linux/arm64 only)
|
||||
value: ${{ steps.build.outputs.tags_aws_lambda_libreoffice }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -91,7 +109,7 @@ runs:
|
||||
if: inputs.docker_hub_username != ''
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_TAGS: ${{ steps.build.outputs.tags }},${{ steps.build.outputs.tags_cloud_run }},${{ steps.build.outputs.tags_aws_lambda }}
|
||||
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 \
|
||||
@@ -102,5 +120,11 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
echo "tags=${{ steps.build.outputs.tags }}"
|
||||
echo "tags_chromium=${{ steps.build.outputs.tags_chromium }}"
|
||||
echo "tags_libreoffice=${{ steps.build.outputs.tags_libreoffice }}"
|
||||
echo "tags_cloud_run=${{ steps.build.outputs.tags_cloud_run }}"
|
||||
echo "tags_cloud_run_chromium=${{ steps.build.outputs.tags_cloud_run_chromium }}"
|
||||
echo "tags_cloud_run_libreoffice=${{ steps.build.outputs.tags_cloud_run_libreoffice }}"
|
||||
echo "tags_aws_lambda=${{ steps.build.outputs.tags_aws_lambda }}"
|
||||
echo "tags_aws_lambda_chromium=${{ steps.build.outputs.tags_aws_lambda_chromium }}"
|
||||
echo "tags_aws_lambda_libreoffice=${{ steps.build.outputs.tags_aws_lambda_libreoffice }}"
|
||||
|
||||
164
.github/actions/build-test-push/build.sh
vendored
164
.github/actions/build-test-push/build.sh
vendored
@@ -55,8 +55,14 @@ fi
|
||||
|
||||
# Build tags arrays.
|
||||
tags=()
|
||||
tags_chromium=()
|
||||
tags_libreoffice=()
|
||||
tags_cloud_run=()
|
||||
tags_cloud_run_chromium=()
|
||||
tags_cloud_run_libreoffice=()
|
||||
tags_aws_lambda=()
|
||||
tags_aws_lambda_chromium=()
|
||||
tags_aws_lambda_libreoffice=()
|
||||
|
||||
IFS='/' read -ra arch <<< "$platform"
|
||||
IFS='.' read -ra semver <<< "$version"
|
||||
@@ -69,55 +75,95 @@ if [ "${#semver[@]}" -eq 3 ]; then
|
||||
minor="${semver[1]}"
|
||||
patch="${semver[2]}"
|
||||
|
||||
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-${arch[1]}")
|
||||
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major-${arch[1]}")
|
||||
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor-${arch[1]}")
|
||||
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor.$patch-${arch[1]}")
|
||||
for suffix in "latest" "$major" "$major.$minor" "$major.$minor.$patch"; do
|
||||
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-${arch[1]}")
|
||||
tags_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-chromium-${arch[1]}")
|
||||
tags_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-libreoffice-${arch[1]}")
|
||||
done
|
||||
|
||||
if [ "$platform" = "linux/amd64" ]; then
|
||||
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-cloudrun")
|
||||
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major-cloudrun")
|
||||
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor-cloudrun")
|
||||
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor.$patch-cloudrun")
|
||||
for suffix in "latest" "$major" "$major.$minor" "$major.$minor.$patch"; do
|
||||
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-cloudrun")
|
||||
tags_cloud_run_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-chromium-cloudrun")
|
||||
tags_cloud_run_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-libreoffice-cloudrun")
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then
|
||||
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-aws-lambda-${arch[1]}")
|
||||
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major-aws-lambda-${arch[1]}")
|
||||
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor-aws-lambda-${arch[1]}")
|
||||
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor.$patch-aws-lambda-${arch[1]}")
|
||||
for suffix in "latest" "$major" "$major.$minor" "$major.$minor.$patch"; do
|
||||
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-aws-lambda-${arch[1]}")
|
||||
tags_aws_lambda_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-chromium-aws-lambda-${arch[1]}")
|
||||
tags_aws_lambda_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-libreoffice-aws-lambda-${arch[1]}")
|
||||
done
|
||||
fi
|
||||
else
|
||||
echo
|
||||
echo "Non-semver version detected, fallback to $version"
|
||||
|
||||
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-${arch[1]}")
|
||||
tags_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-chromium-${arch[1]}")
|
||||
tags_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-libreoffice-${arch[1]}")
|
||||
|
||||
if [ "$platform" = "linux/amd64" ]; then
|
||||
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-cloudrun")
|
||||
tags_cloud_run_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-chromium-cloudrun")
|
||||
tags_cloud_run_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-libreoffice-cloudrun")
|
||||
fi
|
||||
|
||||
if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then
|
||||
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-aws-lambda-${arch[1]}")
|
||||
tags_aws_lambda_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-chromium-aws-lambda-${arch[1]}")
|
||||
tags_aws_lambda_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-libreoffice-aws-lambda-${arch[1]}")
|
||||
fi
|
||||
fi
|
||||
|
||||
tags_flags=()
|
||||
tags_chromium_flags=()
|
||||
tags_libreoffice_flags=()
|
||||
tags_cloud_run_flags=()
|
||||
tags_cloud_run_chromium_flags=()
|
||||
tags_cloud_run_libreoffice_flags=()
|
||||
tags_aws_lambda_flags=()
|
||||
tags_aws_lambda_chromium_flags=()
|
||||
tags_aws_lambda_libreoffice_flags=()
|
||||
|
||||
echo "Will use the following tags:"
|
||||
for tag in "${tags[@]}"; do
|
||||
tags_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
for tag in "${tags_chromium[@]}"; do
|
||||
tags_chromium_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
for tag in "${tags_libreoffice[@]}"; do
|
||||
tags_libreoffice_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
for tag in "${tags_cloud_run[@]}"; do
|
||||
tags_cloud_run_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
for tag in "${tags_cloud_run_chromium[@]}"; do
|
||||
tags_cloud_run_chromium_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
for tag in "${tags_cloud_run_libreoffice[@]}"; do
|
||||
tags_cloud_run_libreoffice_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
for tag in "${tags_aws_lambda[@]}"; do
|
||||
tags_aws_lambda_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
for tag in "${tags_aws_lambda_chromium[@]}"; do
|
||||
tags_aws_lambda_chromium_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
for tag in "${tags_aws_lambda_libreoffice[@]}"; do
|
||||
tags_aws_lambda_libreoffice_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
echo
|
||||
|
||||
# Build images.
|
||||
@@ -145,7 +191,9 @@ join() {
|
||||
|
||||
no_arch_tag="$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version"
|
||||
|
||||
# Full variant.
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
@@ -155,30 +203,102 @@ cmd="docker buildx build \
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
|
||||
# Chromium only variant.
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg-chromium \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
${tags_chromium_flags[*]} \
|
||||
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
|
||||
# LibreOffice only variant.
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg-libreoffice \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
${tags_libreoffice_flags[*]} \
|
||||
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
|
||||
# Cloud Run variants (amd64 only).
|
||||
if [ "$platform" = "linux/amd64" ]; then
|
||||
cmd="docker build \
|
||||
--build-arg DOCKER_REGISTRY=$DOCKER_REGISTRY \
|
||||
--build-arg DOCKER_REPOSITORY=$DOCKER_REPOSITORY \
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg-cloudrun \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
${tags_cloud_run_flags[*]} \
|
||||
-f $DOCKERFILE_CLOUDRUN $DOCKER_BUILD_CONTEXT
|
||||
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg-cloudrun-chromium \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
${tags_cloud_run_chromium_flags[*]} \
|
||||
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg-cloudrun-libreoffice \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
${tags_cloud_run_libreoffice_flags[*]} \
|
||||
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
fi
|
||||
|
||||
# AWS Lambda variants (amd64 + arm64 only).
|
||||
if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then
|
||||
cmd="docker build \
|
||||
--build-arg DOCKER_REGISTRY=$DOCKER_REGISTRY \
|
||||
--build-arg DOCKER_REPOSITORY=$DOCKER_REPOSITORY \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
${tags_aws_lambda_flags[*]} \
|
||||
-f $DOCKERFILE_AWS_LAMBDA $DOCKER_BUILD_CONTEXT
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg-aws-lambda \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
${tags_aws_lambda_flags[*]} \
|
||||
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg-aws-lambda-chromium \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
${tags_aws_lambda_chromium_flags[*]} \
|
||||
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg-aws-lambda-libreoffice \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
${tags_aws_lambda_libreoffice_flags[*]} \
|
||||
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
fi
|
||||
|
||||
echo "✅ Done!"
|
||||
echo "tags=$(join "," "${tags[@]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "tags_chromium=$(join "," "${tags_chromium[@]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "tags_libreoffice=$(join "," "${tags_libreoffice[@]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "tags_cloud_run=$(join "," "${tags_cloud_run[@]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "tags_cloud_run_chromium=$(join "," "${tags_cloud_run_chromium[@]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "tags_cloud_run_libreoffice=$(join "," "${tags_cloud_run_libreoffice[@]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "tags_aws_lambda=$(join "," "${tags_aws_lambda[@]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "tags_aws_lambda_chromium=$(join "," "${tags_aws_lambda_chromium[@]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "tags_aws_lambda_libreoffice=$(join "," "${tags_aws_lambda_libreoffice[@]}")" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
|
||||
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
@@ -12,7 +12,15 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
ignore:
|
||||
# Held at v0.14.2: v0.15.x breaks the headless print-mode paint pipeline
|
||||
# (rAF / ResizeObserver / IntersectionObserver stop firing, blank charts).
|
||||
# See https://github.com/gotenberg/gotenberg/issues/1535.
|
||||
- dependency-name: "github.com/chromedp/chromedp"
|
||||
- dependency-name: "github.com/chromedp/cdproto"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
74
.github/workflows/continuous-delivery.yml
vendored
74
.github/workflows/continuous-delivery.yml
vendored
@@ -13,11 +13,17 @@ 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
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build and push
|
||||
id: build_push
|
||||
@@ -34,11 +40,17 @@ 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
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build and push
|
||||
id: build_push
|
||||
@@ -55,11 +67,17 @@ 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
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build and push
|
||||
id: build_push
|
||||
@@ -76,11 +94,17 @@ 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
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build and push
|
||||
id: build_push
|
||||
@@ -97,11 +121,17 @@ 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
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build and push
|
||||
id: build_push
|
||||
@@ -124,7 +154,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Merge
|
||||
uses: ./.github/actions/merge
|
||||
@@ -132,7 +162,20 @@ jobs:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_ppc64le.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }}"
|
||||
alternate_registry: thecodingmachine
|
||||
|
||||
- name: Merge Chromium
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.release_amd64.outputs.tags_chromium }},${{ needs.release_386.outputs.tags_chromium }},${{ needs.release_ppc64le.outputs.tags_chromium }},${{ needs.release_arm64.outputs.tags_chromium }},${{ needs.release_arm_v7.outputs.tags_chromium }}"
|
||||
|
||||
- name: Merge LibreOffice
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.release_amd64.outputs.tags_libreoffice }},${{ needs.release_386.outputs.tags_libreoffice }},${{ needs.release_ppc64le.outputs.tags_libreoffice }},${{ needs.release_arm64.outputs.tags_libreoffice }},${{ needs.release_arm_v7.outputs.tags_libreoffice }}"
|
||||
|
||||
- name: Merge AWS Lambda
|
||||
uses: ./.github/actions/merge
|
||||
@@ -140,11 +183,24 @@ jobs:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.release_amd64.outputs.tags_aws_lambda }},${{ needs.release_arm64.outputs.tags_aws_lambda }}"
|
||||
alternate_registry: thecodingmachine
|
||||
|
||||
- name: Merge AWS Lambda Chromium
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.release_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.release_arm64.outputs.tags_aws_lambda_chromium }}"
|
||||
|
||||
- name: Merge AWS Lambda LibreOffice
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.release_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.release_arm64.outputs.tags_aws_lambda_libreoffice }}"
|
||||
|
||||
- name: Clean
|
||||
uses: ./.github/actions/clean
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_ppc64le.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }},${{ needs.release_amd64.outputs.tags_aws_lambda }},${{ needs.release_arm64.outputs.tags_aws_lambda }}"
|
||||
tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_ppc64le.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }},${{ needs.release_amd64.outputs.tags_chromium }},${{ needs.release_386.outputs.tags_chromium }},${{ needs.release_ppc64le.outputs.tags_chromium }},${{ needs.release_arm64.outputs.tags_chromium }},${{ needs.release_arm_v7.outputs.tags_chromium }},${{ needs.release_amd64.outputs.tags_libreoffice }},${{ needs.release_386.outputs.tags_libreoffice }},${{ needs.release_ppc64le.outputs.tags_libreoffice }},${{ needs.release_arm64.outputs.tags_libreoffice }},${{ needs.release_arm_v7.outputs.tags_libreoffice }},${{ needs.release_amd64.outputs.tags_aws_lambda }},${{ needs.release_arm64.outputs.tags_aws_lambda }},${{ needs.release_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.release_arm64.outputs.tags_aws_lambda_chromium }},${{ needs.release_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.release_arm64.outputs.tags_aws_lambda_libreoffice }}"
|
||||
|
||||
160
.github/workflows/continuous-integration.yml
vendored
160
.github/workflows/continuous-integration.yml
vendored
@@ -21,10 +21,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
@@ -38,15 +38,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm i
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Run linters
|
||||
run: make lint-prettier
|
||||
@@ -59,10 +59,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
@@ -77,11 +77,17 @@ 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
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
@@ -101,11 +107,17 @@ 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
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
@@ -125,11 +137,17 @@ 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
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
@@ -149,11 +167,17 @@ 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
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
@@ -173,11 +197,17 @@ 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
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
@@ -223,7 +253,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Merge
|
||||
uses: ./.github/actions/merge
|
||||
@@ -232,6 +262,20 @@ jobs:
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.snapshot_amd64.outputs.tags }},${{ needs.snapshot_386.outputs.tags }},${{ needs.snapshot_ppc64le.outputs.tags }},${{ needs.snapshot_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }}"
|
||||
|
||||
- name: Merge Chromium
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.snapshot_amd64.outputs.tags_chromium }},${{ needs.snapshot_386.outputs.tags_chromium }},${{ needs.snapshot_ppc64le.outputs.tags_chromium }},${{ needs.snapshot_arm64.outputs.tags_chromium }},${{ needs.snapshot_arm_v7.outputs.tags_chromium }}"
|
||||
|
||||
- name: Merge LibreOffice
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.snapshot_amd64.outputs.tags_libreoffice }},${{ needs.snapshot_386.outputs.tags_libreoffice }},${{ needs.snapshot_ppc64le.outputs.tags_libreoffice }},${{ needs.snapshot_arm64.outputs.tags_libreoffice }},${{ needs.snapshot_arm_v7.outputs.tags_libreoffice }}"
|
||||
|
||||
- name: Merge AWS Lambda
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
@@ -239,12 +283,26 @@ jobs:
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.snapshot_amd64.outputs.tags_aws_lambda }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda }}"
|
||||
|
||||
- name: Merge AWS Lambda Chromium
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.snapshot_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda_chromium }}"
|
||||
|
||||
- name: Merge AWS Lambda LibreOffice
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.snapshot_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda_libreoffice }}"
|
||||
|
||||
- name: Clean
|
||||
uses: ./.github/actions/clean
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.snapshot_amd64.outputs.tags }},${{ needs.snapshot_386.outputs.tags }},${{ needs.snapshot_ppc64le.outputs.tags }},${{ needs.snapshot_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }},${{ needs.snapshot_amd64.outputs.tags_aws_lambda }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda }}"
|
||||
tags: "${{ needs.snapshot_amd64.outputs.tags }},${{ needs.snapshot_386.outputs.tags }},${{ needs.snapshot_ppc64le.outputs.tags }},${{ needs.snapshot_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }},${{ needs.snapshot_amd64.outputs.tags_chromium }},${{ needs.snapshot_386.outputs.tags_chromium }},${{ needs.snapshot_ppc64le.outputs.tags_chromium }},${{ needs.snapshot_arm64.outputs.tags_chromium }},${{ needs.snapshot_arm_v7.outputs.tags_chromium }},${{ needs.snapshot_amd64.outputs.tags_libreoffice }},${{ needs.snapshot_386.outputs.tags_libreoffice }},${{ needs.snapshot_ppc64le.outputs.tags_libreoffice }},${{ needs.snapshot_arm64.outputs.tags_libreoffice }},${{ needs.snapshot_arm_v7.outputs.tags_libreoffice }},${{ needs.snapshot_amd64.outputs.tags_aws_lambda }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda }},${{ needs.snapshot_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda_chromium }},${{ needs.snapshot_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda_libreoffice }}"
|
||||
|
||||
edge_amd64:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
@@ -254,11 +312,17 @@ 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
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
@@ -277,11 +341,17 @@ 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
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
@@ -300,11 +370,17 @@ 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
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
@@ -323,11 +399,17 @@ 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
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
@@ -346,11 +428,17 @@ 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
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build, test and push
|
||||
id: build_test_push
|
||||
@@ -372,7 +460,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Merge
|
||||
uses: ./.github/actions/merge
|
||||
@@ -380,7 +468,20 @@ jobs:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_ppc64le.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }}"
|
||||
alternate_registry: thecodingmachine
|
||||
|
||||
- name: Merge Chromium
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.edge_amd64.outputs.tags_chromium }},${{ needs.edge_386.outputs.tags_chromium }},${{ needs.edge_ppc64le.outputs.tags_chromium }},${{ needs.edge_arm64.outputs.tags_chromium }},${{ needs.edge_arm_v7.outputs.tags_chromium }}"
|
||||
|
||||
- name: Merge LibreOffice
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.edge_amd64.outputs.tags_libreoffice }},${{ needs.edge_386.outputs.tags_libreoffice }},${{ needs.edge_ppc64le.outputs.tags_libreoffice }},${{ needs.edge_arm64.outputs.tags_libreoffice }},${{ needs.edge_arm_v7.outputs.tags_libreoffice }}"
|
||||
|
||||
- name: Merge AWS Lambda
|
||||
uses: ./.github/actions/merge
|
||||
@@ -388,11 +489,24 @@ jobs:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.edge_amd64.outputs.tags_aws_lambda }},${{ needs.edge_arm64.outputs.tags_aws_lambda }}"
|
||||
alternate_registry: thecodingmachine
|
||||
|
||||
- name: Merge AWS Lambda Chromium
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.edge_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.edge_arm64.outputs.tags_aws_lambda_chromium }}"
|
||||
|
||||
- name: Merge AWS Lambda LibreOffice
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.edge_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.edge_arm64.outputs.tags_aws_lambda_libreoffice }}"
|
||||
|
||||
- name: Clean
|
||||
uses: ./.github/actions/clean
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_ppc64le.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }},${{ needs.edge_amd64.outputs.tags_aws_lambda }},${{ needs.edge_arm64.outputs.tags_aws_lambda }}"
|
||||
tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_ppc64le.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }},${{ needs.edge_amd64.outputs.tags_chromium }},${{ needs.edge_386.outputs.tags_chromium }},${{ needs.edge_ppc64le.outputs.tags_chromium }},${{ needs.edge_arm64.outputs.tags_chromium }},${{ needs.edge_arm_v7.outputs.tags_chromium }},${{ needs.edge_amd64.outputs.tags_libreoffice }},${{ needs.edge_386.outputs.tags_libreoffice }},${{ needs.edge_ppc64le.outputs.tags_libreoffice }},${{ needs.edge_arm64.outputs.tags_libreoffice }},${{ needs.edge_arm_v7.outputs.tags_libreoffice }},${{ needs.edge_amd64.outputs.tags_aws_lambda }},${{ needs.edge_arm64.outputs.tags_aws_lambda }},${{ needs.edge_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.edge_arm64.outputs.tags_aws_lambda_chromium }},${{ needs.edge_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.edge_arm64.outputs.tags_aws_lambda_libreoffice }}"
|
||||
|
||||
2
.github/workflows/pull-request-cleanup.yml
vendored
2
.github/workflows/pull-request-cleanup.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Cleanup
|
||||
uses: ./.github/actions/clean
|
||||
|
||||
@@ -1 +1 @@
|
||||
24.11.0
|
||||
24.15.0
|
||||
|
||||
181
AGENTS.md
181
AGENTS.md
@@ -1,181 +0,0 @@
|
||||
# Operational Guidelines for Gotenberg
|
||||
|
||||
You are working on **Gotenberg**, a Docker-based API for converting documents to PDF. It is a widely used production dependency. Stability and backward compatibility are paramount. When in doubt about whether a change is breaking, flag it rather than assuming it's safe.
|
||||
|
||||
## Mandatory Workflow
|
||||
|
||||
Every task MUST follow these five steps in order. Do not skip any step.
|
||||
|
||||
### Step 1 — Plan
|
||||
|
||||
Before writing any code, produce a plan that covers:
|
||||
|
||||
- **Problem statement**: What needs to change and why.
|
||||
- **Proposed solution**: The recommended approach with enough detail to implement (files to modify, interface changes, pipeline positioning, form fields, etc.).
|
||||
- **Alternatives considered**: At least one alternative approach when pertinent, with a brief explanation of why the proposed solution is preferred.
|
||||
- **Scope**: List every file that will be created or modified.
|
||||
- **Testing strategy**: Which integration test tags will be affected, what new scenarios are needed, and whether unit tests are required.
|
||||
|
||||
Present the plan to the user and wait for approval before proceeding to Step 2. If the user provides a plan, validate it against the codebase and flag any issues before implementing.
|
||||
|
||||
### Step 2 — Implement
|
||||
|
||||
Implement the approved plan following the coding standards and patterns described in this document. After implementation, verify the build compiles (`go build ./...`).
|
||||
|
||||
### Step 3 — Test
|
||||
|
||||
Write or update tests based on the plan's testing strategy:
|
||||
|
||||
- **Integration tests** (primary): Gherkin scenarios in `test/integration/features/`. See [`test/integration/AGENTS.md`](test/integration/AGENTS.md) for the full reference.
|
||||
- **Unit tests** (when applicable): Table-driven tests in `*_test.go` files using mocks from `pkg/gotenberg/mocks.go`.
|
||||
|
||||
### Step 4 — Review
|
||||
|
||||
Self-review the implementation against the [Review Checklist](#review-checklist). Fix any issues found before presenting the result to the user.
|
||||
|
||||
### Step 5 — Commit
|
||||
|
||||
Present the review to the user and **wait for explicit approval**. Do NOT commit until the user confirms. Once approved, create a commit following the [Conventional Commits](https://www.conventionalcommits.org/) specification:
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
```
|
||||
|
||||
Common types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `ci`, `build`. The scope should match the module or area of the change (e.g., `chromium`, `pdfengines`, `api`).
|
||||
|
||||
Stage only the files related to the change. Do not use `git add -A` or `git add .`.
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
- **Backward compatibility is law.** Never modify existing CLI flags, environment variables, or API form fields unless explicitly instructed to perform a breaking change. Flag any breaking change immediately.
|
||||
- **Defensive programming.** Assume input is malformed. Handle errors explicitly. Never panic.
|
||||
- **Atomic commits.** One feature or fix per PR. Isolate refactoring from feature work.
|
||||
- **Idiomatic Go.** Follow "Effective Go" principles. All exported symbols must have GoDoc comments starting with their name.
|
||||
|
||||
## 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. When adding features, determine if they belong in an existing module or require a new one.
|
||||
|
||||
## Codebase Navigation
|
||||
|
||||
- Start with `pkg/gotenberg/` for core interfaces and `pkg/modules/` for feature implementations.
|
||||
- The integration test infrastructure in `test/integration/scenario/` is well-structured — read `scenario.go` and `containers.go` to understand the Gherkin step definitions before writing new tests.
|
||||
- Mocks for all major interfaces are in `pkg/gotenberg/mocks.go` — use them for unit tests rather than creating new ones.
|
||||
- Import ordering is enforced: standard library, third-party, then `github.com/gotenberg/gotenberg/v8` — separated by blank lines.
|
||||
- When making changes, run only the relevant integration test tag rather than the full suite (40min timeout).
|
||||
- Telemetry infrastructure lives in `pkg/gotenberg/telemetry.go` (global Logger, Tracer, Meter) and `pkg/gotenberg/internal/` (log handlers, OTEL SDK init). HTTP semantic conventions are in `pkg/gotenberg/semconv/`.
|
||||
|
||||
---
|
||||
|
||||
## Makefile — the Only Build Interface
|
||||
|
||||
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 Docker image | Before integration tests, or to verify compilation |
|
||||
| `make run` | Run Gotenberg container via `docker compose` | Manual testing. Flags are configured via Makefile variables and compose.yaml |
|
||||
| `make telemetry` | Start OpenTelemetry collector and OpenObserve | When testing telemetry locally |
|
||||
| `make down` | Stop all compose containers | After manual testing |
|
||||
| `make fmt` | Format Go code (`go fix`, `golangci-lint fmt`, `go mod tidy`) | Before every commit |
|
||||
| `make lint` | Lint Go code (strict `.golangci.yml` config) | Before every commit. Zero errors permitted |
|
||||
| `make lint-prettier` | Lint non-Go files (Markdown, YAML, etc.) with Prettier | Before every commit |
|
||||
| `make prettify` | Format non-Go files (Markdown, YAML, etc.) with Prettier | Before every commit |
|
||||
| `make test-unit` | Run unit tests (`go test -race ./...`) | After code changes to `pkg/` |
|
||||
| `make test-integration` | Run integration tests (Gherkin/Godog, 40min timeout) | After any feature or route change |
|
||||
| `make godoc` | Serve GoDoc at `localhost:6060` | To verify documentation |
|
||||
|
||||
## Module System
|
||||
|
||||
Gotenberg uses a self-registering module architecture inspired by CaddyServer. Each module:
|
||||
|
||||
- Lives in `pkg/modules/<name>/`
|
||||
- Implements the `gotenberg.Module` interface (at minimum `Descriptor()`)
|
||||
- May also implement `gotenberg.Provisioner`, `gotenberg.Validator`, or `gotenberg.Debuggable`
|
||||
- Self-registers via `init()` and is wired through `pkg/standard/`
|
||||
|
||||
When adding a feature, first determine if it belongs in an existing module. Only create a new module if the feature represents a genuinely separate concern.
|
||||
|
||||
## Coding Patterns
|
||||
|
||||
- **Error handling:** Always wrap errors with context using `fmt.Errorf("description: %w", err)`. Never swallow errors silently.
|
||||
- **Import ordering:** Enforced by `gci` — standard library, then third-party, then `github.com/gotenberg/gotenberg/v8`. Three groups separated by blank lines.
|
||||
- **Mocks:** Comprehensive mock implementations for all major interfaces live in `pkg/gotenberg/mocks.go`. Use these for unit tests.
|
||||
- **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 respectively.
|
||||
- **No business logic in `cmd/`:** The `cmd/gotenberg/` package is strictly for wiring and startup.
|
||||
|
||||
---
|
||||
|
||||
## Review Checklist
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
- [ ] No existing CLI flags renamed or removed
|
||||
- [ ] No existing environment variables renamed or removed
|
||||
- [ ] No existing API form fields renamed or removed
|
||||
- [ ] No existing HTTP endpoints changed or removed
|
||||
- [ ] No changes to default values that alter existing behavior
|
||||
- [ ] Deprecated flags have both old and new names registered, with `fs.MarkDeprecated()`
|
||||
|
||||
If any of these are violated, the change **must** be flagged as a breaking change.
|
||||
|
||||
### Linting Standards
|
||||
|
||||
The `.golangci.yml` enforces strict rules including: `gosec`, `govet`, `errcheck`, `staticcheck`, `dupl`, `bodyclose`, `exhaustive`, `errname`, `sloglint`, `gocritic`, and more. Zero linting errors are permitted.
|
||||
|
||||
Formatters enforce `gci`, `gofmt`, `gofumpt`, `goimports` with import ordering:
|
||||
|
||||
1. Standard library
|
||||
2. Third-party packages
|
||||
3. `github.com/gotenberg/gotenberg/v8`
|
||||
|
||||
Three groups separated by blank lines.
|
||||
|
||||
### Code Quality
|
||||
|
||||
- Errors are wrapped with context: `fmt.Errorf("description: %w", err)`. No swallowed errors.
|
||||
- No business logic in `cmd/`.
|
||||
- No panics in production code paths.
|
||||
- Input is validated defensively.
|
||||
- New features belong in the correct module (or justify a new one).
|
||||
|
||||
### Documentation
|
||||
|
||||
- Every exported function, type, constant, and variable has a GoDoc comment starting with its name.
|
||||
- New packages include a `doc.go` file.
|
||||
- `README.md` is not modified unless explicitly requested.
|
||||
|
||||
### Definition of Done
|
||||
|
||||
A change is ready to merge only when:
|
||||
|
||||
1. Code compiles: `go build ./...`
|
||||
2. Code is formatted: `make fmt`
|
||||
3. All linters pass: `make lint` and `make lint-prettier`
|
||||
4. Integration tests pass: `make test-integration` (at minimum, the relevant `TAGS`)
|
||||
5. Unit tests pass: `make test-unit`
|
||||
6. All exported symbols and new packages have compliant GoDoc
|
||||
7. Bruno collection is updated (if routes were added or modified)
|
||||
|
||||
---
|
||||
|
||||
## Scoped Guidelines
|
||||
|
||||
Detailed guidelines for specific areas of the codebase live in their own `AGENTS.md` files:
|
||||
|
||||
- [`test/integration/AGENTS.md`](test/integration/AGENTS.md) — Integration test framework, Gherkin step reference, available tags, and how to write new tests.
|
||||
- [`.bruno/AGENTS.md`](.bruno/AGENTS.md) — Bruno API collection structure, `.bru` file format, conventions, and route update checklist.
|
||||
- [`pkg/modules/pdfengines/AGENTS.md`](pkg/modules/pdfengines/AGENTS.md) — How to add new PDF engine features (Makefile variable and flag).
|
||||
@@ -1,3 +0,0 @@
|
||||
# Claude Code — Gotenberg
|
||||
|
||||
Read [AGENTS.md](AGENTS.md) first. It contains everything: core principles, project layout, coding standards, the mandatory 4-step workflow (Plan → Implement → Test → Review), integration test reference, review checklist, and Bruno collection guidelines.
|
||||
207
CONTRIBUTING.md
207
CONTRIBUTING.md
@@ -1,39 +1,59 @@
|
||||
# Contributing to Gotenberg
|
||||
|
||||
Thank you for your interest in contributing to Gotenberg! This guide will help you get started.
|
||||
Gotenberg is a Docker-based API for converting documents to PDF. Two rules override everything else:
|
||||
|
||||
## Getting Started
|
||||
- **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.
|
||||
|
||||
### Prerequisites
|
||||
## Toolchain
|
||||
|
||||
- Go (see version in `go.mod`)
|
||||
- Module: `github.com/gotenberg/gotenberg/v8`
|
||||
- Go: see version in `go.mod`
|
||||
- Docker
|
||||
- Node.js (see version in `.node-version`) — for Prettier linting
|
||||
- Node.js (see `.node-version`), for Prettier linting
|
||||
- [golangci-lint](https://golangci-lint.run/) v2+
|
||||
|
||||
### Build and Run
|
||||
## Before you start
|
||||
|
||||
```bash
|
||||
make build # Build the Docker image
|
||||
make run # Run a local Gotenberg container
|
||||
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).
|
||||
```
|
||||
|
||||
### Development Loop
|
||||
Key interfaces live in `pkg/gotenberg/`: `Module`, `Provisioner`, `Validator`, `Debuggable`. Every module implements `Descriptor()` and self-registers via `init()`.
|
||||
|
||||
```bash
|
||||
# Write your code, then:
|
||||
make fmt # Format Go code
|
||||
make prettify # Format non-Go files (Markdown, YAML, etc.)
|
||||
make lint # Lint Go code (zero errors permitted)
|
||||
make lint-prettier # Lint non-Go files
|
||||
make test-unit # Run unit tests
|
||||
make build # Build the Docker image (required before integration tests)
|
||||
make test-integration # Run all integration tests
|
||||
make telemetry # Start OpenTelemetry collector and OpenObserve
|
||||
make down # Stop all compose containers
|
||||
```
|
||||
## Setup and Makefile
|
||||
|
||||
To run only the integration tests relevant to your change:
|
||||
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
|
||||
@@ -41,34 +61,127 @@ make test-integration TAGS=chromium-convert-html
|
||||
make test-integration TAGS="merge,split"
|
||||
```
|
||||
|
||||
## Submitting a Pull Request
|
||||
## Code conventions
|
||||
|
||||
Before opening a PR, verify:
|
||||
### Module system
|
||||
|
||||
1. Code compiles: `make build`
|
||||
2. Code is formatted: `make fmt` and `make prettify`
|
||||
3. All linters pass: `make lint` and `make lint-prettier`
|
||||
4. Integration tests pass: `make test-integration` (at minimum, the relevant tags)
|
||||
5. Unit tests pass: `make test-unit`
|
||||
6. All exported symbols and new packages have GoDoc comments
|
||||
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/`.
|
||||
|
||||
### Guidelines
|
||||
Determine if a feature belongs in an existing module before creating a new one. Only create a new module for a genuinely separate concern.
|
||||
|
||||
- **Conventional Commits.** Commit messages must follow the [Conventional Commits](https://www.conventionalcommits.org/) specification (e.g., `feat(chromium): add screenshot endpoint`, `fix(api): handle empty body`).
|
||||
- **One thing per PR.** Keep features, bug fixes, and refactoring in separate PRs.
|
||||
- **Backward compatibility matters.** Do not rename or remove existing CLI flags, environment variables, or API form fields without discussion.
|
||||
- **Integration tests first.** When adding a feature or route, start by writing the Gherkin scenario in `test/integration/features/`.
|
||||
- **Logging uses `gotenberg.Logger(mod)`** with context-aware calls (`*Context` variants).
|
||||
- **External operations have OTEL traces** with appropriate SpanKind and semconv attributes.
|
||||
- **No business logic in `cmd/`.** All logic belongs in `pkg/`.
|
||||
The `cmd/gotenberg/` package is strictly for wiring and startup. No business logic.
|
||||
|
||||
## Detailed Guidelines
|
||||
### Backward compatibility
|
||||
|
||||
The [`AGENTS.md`](AGENTS.md) files contain comprehensive guidelines used by both human contributors and AI-assisted tools:
|
||||
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.
|
||||
|
||||
| File | What it covers |
|
||||
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [`AGENTS.md`](AGENTS.md) | Core principles, mandatory workflow, project layout, coding patterns, module system, Makefile reference, review checklist |
|
||||
| [`test/integration/AGENTS.md`](test/integration/AGENTS.md) | Integration test framework (Godog/Gherkin), available tags, step reference, how to write new tests |
|
||||
| [`.bruno/AGENTS.md`](.bruno/AGENTS.md) | Bruno API collection structure, `.bru` file format, conventions, route update checklist |
|
||||
| [`pkg/modules/pdfengines/AGENTS.md`](pkg/modules/pdfengines/AGENTS.md) | How to add new PDF engine features (Makefile variable and flag) |
|
||||
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.
|
||||
|
||||
### Error messages
|
||||
|
||||
Client- and operator-facing error messages state what failed, why when non-obvious, and how to fix it when a fix exists. Internal errors (the wrapped `fmt.Errorf` chains that only reach logs) are exempt; keep them precise and technical.
|
||||
|
||||
- Client (HTTP response body): name the offending form field and its valid values. Never return a bare `http.StatusText()`.
|
||||
- Operator (startup, `Provision`, `Validate`): name the environment variable or flag to set, plus the path or value checked.
|
||||
- Security and filtering errors stay generic for clients. Don't reveal allow/deny lists or private-IP policy. Log the specific reason for operators.
|
||||
- No hedging ("while others may have failed"). No raw `os.Stat` or exec output in the human-facing remedy.
|
||||
|
||||
### Logging
|
||||
|
||||
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).
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Gemini — Gotenberg
|
||||
|
||||
Read [AGENTS.md](AGENTS.md) first. It contains everything: core principles, project layout, coding standards, the mandatory 4-step workflow (Plan → Implement → Test → Review), integration test reference, review checklist, and Bruno collection guidelines.
|
||||
32
Makefile
32
Makefile
@@ -5,8 +5,9 @@ help: ## Show the help
|
||||
@grep -hE '^[A-Za-z0-9_ \-]*?:.*##.*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.PHONY: build
|
||||
build: ## Build the Gotenberg's Docker image
|
||||
build: ## Build the Gotenberg's Docker image (use TARGET=gotenberg-chromium or TARGET=gotenberg-libreoffice for variants)
|
||||
docker build \
|
||||
--target $(TARGET) \
|
||||
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
|
||||
-f $(DOCKERFILE) $(DOCKER_BUILD_CONTEXT)
|
||||
|
||||
@@ -17,6 +18,8 @@ GOTENBERG_BUILD_DEBUG_DATA=true
|
||||
API_PORT=3000
|
||||
API_PORT_FROM_ENV=
|
||||
API_BIND_IP=
|
||||
API_TLS_CERT_FILE=
|
||||
API_TLS_KEY_FILE=
|
||||
API_START_TIMEOUT=30s
|
||||
API_TIMEOUT=30s
|
||||
API_BODY_LIMIT=
|
||||
@@ -26,7 +29,10 @@ API_ENABLE_BASIC_AUTH=false
|
||||
GOTENBERG_API_BASIC_AUTH_USERNAME=
|
||||
GOTENBERG_API_BASIC_AUTH_PASSWORD=
|
||||
API_DOWNLOAD_FROM_ALLOW_LIST=
|
||||
API_DOWNLOAD_FROM_DENY_LIST=
|
||||
API_DOWNLOAD_FROM_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)
|
||||
API_DOWNLOAD_FROM_DENY_PRIVATE_IPS=false
|
||||
API_DOWNLOAD_FROM_DENY_PUBLIC_IPS=false
|
||||
API_DOWNLOAD_FROM_ENABLE_ENVIRONMENT_PROXY=false
|
||||
API_DOWNLOAD_FROM_MAX_RETRY=4
|
||||
API_DISABLE_DOWNLOAD_FROM=false
|
||||
API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=true
|
||||
@@ -46,8 +52,11 @@ CHROMIUM_DISABLE_WEB_SECURITY=false
|
||||
CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false
|
||||
CHROMIUM_HOST_RESOLVER_RULES=
|
||||
CHROMIUM_PROXY_SERVER=
|
||||
CHROMIUM_ENABLE_ENVIRONMENT_PROXY=false
|
||||
CHROMIUM_ALLOW_LIST=
|
||||
CHROMIUM_DENY_LIST=^file:(?!//\/tmp/).*
|
||||
CHROMIUM_DENY_PRIVATE_IPS=false
|
||||
CHROMIUM_DENY_PUBLIC_IPS=false
|
||||
CHROMIUM_CLEAR_CACHE=false
|
||||
CHROMIUM_CLEAR_COOKIES=false
|
||||
CHROMIUM_DISABLE_JAVASCRIPT=false
|
||||
@@ -57,11 +66,17 @@ 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_DENY_PRIVATE_IPS=false
|
||||
LIBREOFFICE_DENY_PUBLIC_IPS=false
|
||||
LIBREOFFICE_ENABLE_ENVIRONMENT_PROXY=false
|
||||
LIBREOFFICE_DISABLE_ROUTES=false
|
||||
LOG_LEVEL=info
|
||||
LOG_FIELDS_PREFIX=
|
||||
LOG_STD_FORMAT=auto
|
||||
LOG_STD_ENABLE_GCP_FIELDS=false
|
||||
LOG_STD_LEVEL_CASE=lower
|
||||
PDFENGINES_DISABLE_ROUTES=false
|
||||
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
|
||||
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
|
||||
@@ -75,10 +90,11 @@ PDFENGINES_WATERMARK_ENGINES=pdfcpu,pdftk
|
||||
PDFENGINES_STAMP_ENGINES=pdfcpu,pdftk
|
||||
PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
|
||||
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
|
||||
PDFENGINES_EMBED_ENGINES=pdfcpu
|
||||
PDFENGINES_EMBED_ENGINES=qpdf,pdfcpu
|
||||
PDFENGINES_EMBED_METADATA_ENGINES=qpdf
|
||||
PDFENGINES_FACTUR_X_ENGINES=qpdf
|
||||
PROMETHEUS_NAMESPACE=gotenberg
|
||||
PROMETHEUS_COLLECT_INTERVAL=1s
|
||||
PROMETHEUS_DISABLE_ROUTE_LOGGING=false
|
||||
PROMETHEUS_DISABLE_ROUTE_TELEMETRY=true
|
||||
PROMETHEUS_DISABLE_COLLECT=false
|
||||
PROMETHEUS_METRICS_PATH=/prometheus/metrics
|
||||
@@ -91,9 +107,10 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
||||
OTEL_EXPORTER_OTLP_INSECURE=true
|
||||
WEBHOOK_ENABLE_SYNC_MODE=false
|
||||
WEBHOOK_ALLOW_LIST=
|
||||
WEBHOOK_DENY_LIST=
|
||||
WEBHOOK_ERROR_ALLOW_LIST=
|
||||
WEBHOOK_ERROR_DENY_LIST=
|
||||
WEBHOOK_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)
|
||||
WEBHOOK_DENY_PRIVATE_IPS=false
|
||||
WEBHOOK_DENY_PUBLIC_IPS=false
|
||||
WEBHOOK_ENABLE_ENVIRONMENT_PROXY=false
|
||||
WEBHOOK_MAX_RETRY=4
|
||||
WEBHOOK_RETRY_MIN_WAIT=1s
|
||||
WEBHOOK_RETRY_MAX_WAIT=30s
|
||||
@@ -155,6 +172,7 @@ NO_CONCURRENCY=false
|
||||
# stamp
|
||||
# pdfengines-rotate
|
||||
# rotate
|
||||
# factur-x
|
||||
# pdfengines-bookmarks
|
||||
# bookmarks
|
||||
# prometheus-metrics
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="https://user-images.githubusercontent.com/8983173/130322857-185831e2-f041-46eb-a17f-0a69d066c4e5.png" alt="Gotenberg Logo" width="150" height="150" />
|
||||
<img src="https://raw.githubusercontent.com/gotenberg/art/master/logo.png" alt="Gotenberg Logo" width="150" height="150" />
|
||||
<h3 align="center">Gotenberg</h3>
|
||||
<p align="center">A Docker-based API for converting documents to PDF</p>
|
||||
<p align="center">
|
||||
@@ -63,6 +63,8 @@ If Gotenberg powers your workflow or your business, consider [**becoming a spons
|
||||
|
||||
- [TheCodingMachine](https://thecodingmachine.com/)
|
||||
- [pdfme](https://pdfme.com/)
|
||||
- [PDFBolt](https://pdfbolt.com)
|
||||
- [FileToPDF.dev](https://filetopdf.dev)
|
||||
|
||||
**Powered By**
|
||||
|
||||
|
||||
34
SECURITY.md
34
SECURITY.md
@@ -2,40 +2,28 @@
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Please ensure to keep your environment up to date and use only the latest version of Gotenberg.
|
||||
Security updates and patches will be applied only to the most recent version.
|
||||
Only the latest version receives security updates and patches. Keep your environment up to date.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Your help in identifying vulnerabilities in our project is much appreciated.
|
||||
We take all reports regarding security seriously.
|
||||
Do not publish vulnerabilities publicly. Report them through [GitHub's private vulnerability reporting](https://github.com/gotenberg/gotenberg/security/advisories/new).
|
||||
|
||||
If you discover a security vulnerability, please refrain from publishing it publicly.
|
||||
Instead, kindly send us the details via email to _neuhart [dot] julien [at] gmail [dot] com_.
|
||||
|
||||
In the subject of your email, please indicate that it's a security vulnerability report for Gotenberg.
|
||||
In your message, please include:
|
||||
Include:
|
||||
|
||||
- A detailed description of the vulnerability.
|
||||
- The steps to reproduce the issue.
|
||||
- Any potential impact of the vulnerability on the users or system.
|
||||
- Steps to reproduce the issue.
|
||||
- Potential impact on users or the system.
|
||||
|
||||
Please remember that this process is done in a _'best-effort'_ manner.
|
||||
This means we strive to respond and act as quickly as possible, but the speed may vary depending on the severity of
|
||||
the issue and our resources.
|
||||
|
||||
Thank you in advance for helping to keep our project safe!
|
||||
This process is handled on a best-effort basis. Response speed may vary depending on severity and available resources.
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
Once we have received your vulnerability report, we will work to validate and reproduce the issue.
|
||||
If we can confirm the vulnerability, we will proceed to:
|
||||
Once a report is received and confirmed:
|
||||
|
||||
- Work on a fix and a release timeline.
|
||||
- Notify you when the fix has been implemented and released.
|
||||
- Credit you for discovering the vulnerability (unless you request anonymity).
|
||||
- Please note that we will do our best to keep you informed about the progress towards resolving the issue.
|
||||
- A fix and release timeline will be prepared.
|
||||
- The reporter will be notified when the fix is released.
|
||||
- The reporter will be credited for the discovery (unless anonymity is requested).
|
||||
|
||||
## Comments on this Policy
|
||||
|
||||
If you have suggestions on how this process could be improved, please submit a pull request.
|
||||
Submit a pull request with suggestions for improving this process.
|
||||
|
||||
607
build/Dockerfile
607
build/Dockerfile
@@ -1,7 +1,7 @@
|
||||
# ARG instructions do not create additional layers. Instead, next layers will
|
||||
# concatenate them. Also, we have to repeat ARG instructions in each build
|
||||
# stage that uses them.
|
||||
ARG GOLANG_VERSION=1.26.0
|
||||
ARG GOLANG_VERSION=1.26.5
|
||||
|
||||
# ----------------------------------------------
|
||||
# pdfcpu binary build stage
|
||||
@@ -11,22 +11,22 @@ ARG GOLANG_VERSION=1.26.0
|
||||
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
|
||||
|
||||
# See https://github.com/pdfcpu/pdfcpu/releases.
|
||||
ARG PDFCPU_VERSION=v0.11.1
|
||||
ARG PDFCPU_VERSION=v0.13.0
|
||||
ENV CGO_ENABLED=0
|
||||
|
||||
# Define the working directory outside of $GOPATH (we're using go modules).
|
||||
WORKDIR /home
|
||||
|
||||
RUN curl -Ls "https://github.com/pdfcpu/pdfcpu/archive/refs/tags/$PDFCPU_VERSION.tar.gz" -o pdfcpu.tar.gz &&\
|
||||
tar --strip-components=1 -xvzf pdfcpu.tar.gz
|
||||
RUN curl -Ls "https://github.com/pdfcpu/pdfcpu/archive/refs/tags/$PDFCPU_VERSION.tar.gz" -o pdfcpu.tar.gz \
|
||||
&& tar --strip-components=1 -xvzf pdfcpu.tar.gz
|
||||
|
||||
# Install module dependencies.
|
||||
RUN go mod download &&\
|
||||
go mod verify
|
||||
RUN go mod download \
|
||||
&& go mod verify
|
||||
|
||||
RUN go build -o pdfcpu -ldflags "-s -w -X 'main.version=$PDFCPU_VERSION' -X 'github.com/pdfcpu/pdfcpu/pkg/pdfcpu.VersionStr=$PDFCPU_VERSION' -X main.builtBy=gotenberg" ./cmd/pdfcpu &&\
|
||||
RUN go build -o pdfcpu -ldflags "-s -w -X 'main.version=$PDFCPU_VERSION' -X 'github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model.VersionStr=$PDFCPU_VERSION' -X main.builtBy=gotenberg" ./cmd/pdfcpu \
|
||||
# Verify installation.
|
||||
./pdfcpu version
|
||||
&& ./pdfcpu version
|
||||
|
||||
# ----------------------------------------------
|
||||
# Gotenberg binary build stage
|
||||
@@ -42,14 +42,16 @@ WORKDIR /home
|
||||
# Install module dependencies.
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
RUN go mod download &&\
|
||||
go mod verify
|
||||
RUN go mod download \
|
||||
&& go mod verify
|
||||
|
||||
# Copy the source code.
|
||||
COPY cmd ./cmd
|
||||
COPY pkg ./pkg
|
||||
|
||||
RUN go build -o gotenberg -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go
|
||||
RUN go build -o gotenberg -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go \
|
||||
&& go build -o gotenberg-chromium -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg-chromium/main.go \
|
||||
&& go build -o gotenberg-libreoffice -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg-libreoffice/main.go
|
||||
|
||||
# ----------------------------------------------
|
||||
# Custom JRE stage
|
||||
@@ -57,10 +59,9 @@ 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.
|
||||
@@ -74,6 +75,25 @@ RUN jlink \
|
||||
--compress=2 \
|
||||
--output /custom-jre
|
||||
|
||||
# ----------------------------------------------
|
||||
# Downloader stage
|
||||
# ----------------------------------------------
|
||||
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
|
||||
|
||||
WORKDIR /downloads
|
||||
|
||||
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.3.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
|
||||
|
||||
# ----------------------------------------------
|
||||
# Base image stage
|
||||
# ----------------------------------------------
|
||||
@@ -82,125 +102,80 @@ FROM debian:13-slim AS base-image-stage
|
||||
ARG TIMEZONE=UTC
|
||||
ENV TZ=$TIMEZONE
|
||||
|
||||
COPY --from=custom-jre-stage /custom-jre /opt/java
|
||||
COPY --link --from=custom-jre-stage /custom-jre /opt/java
|
||||
|
||||
ENV PATH="/opt/java/bin:${PATH}"
|
||||
|
||||
# ----------------------------------------------
|
||||
# Final stage
|
||||
# Common stage
|
||||
# ----------------------------------------------
|
||||
FROM base-image-stage
|
||||
FROM base-image-stage AS common-stage
|
||||
|
||||
ARG GOTENBERG_VERSION=snapshot
|
||||
ARG GOTENBERG_USER_GID=1001
|
||||
ARG GOTENBERG_USER_UID=1001
|
||||
# See https://github.com/googlefonts/noto-emoji/releases.
|
||||
ARG NOTO_COLOR_EMOJI_VERSION=v2.051
|
||||
# See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
|
||||
ARG PDFTK_VERSION=v3.3.3
|
||||
|
||||
LABEL org.opencontainers.image.title="Gotenberg" \
|
||||
org.opencontainers.image.description="A containerized API for seamless PDF conversion." \
|
||||
org.opencontainers.image.version="$GOTENBERG_VERSION" \
|
||||
org.opencontainers.image.authors="Julien Neuhart <neuhart.julien@gmail.com>" \
|
||||
org.opencontainers.image.documentation="https://gotenberg.dev" \
|
||||
org.opencontainers.image.source="https://github.com/gotenberg/gotenberg"
|
||||
# Create a non-root user.
|
||||
# All processes in the Docker container will run with this dedicated user.
|
||||
RUN groupadd --gid "$GOTENBERG_USER_GID" gotenberg \
|
||||
&& useradd --uid "$GOTENBERG_USER_UID" --gid gotenberg --shell /bin/bash --home /home/gotenberg --no-create-home gotenberg \
|
||||
&& mkdir /home/gotenberg \
|
||||
&& chown gotenberg: /home/gotenberg
|
||||
|
||||
RUN \
|
||||
# Create a non-root user.
|
||||
# All processes in the Docker container will run with this dedicated user.
|
||||
groupadd --gid "$GOTENBERG_USER_GID" gotenberg &&\
|
||||
useradd --uid "$GOTENBERG_USER_UID" --gid gotenberg --shell /bin/bash --home /home/gotenberg --no-create-home gotenberg &&\
|
||||
mkdir /home/gotenberg &&\
|
||||
chown gotenberg: /home/gotenberg
|
||||
|
||||
RUN \
|
||||
# Install system dependencies required for the next instructions or debugging.
|
||||
# Note: tini is a helper for reaping zombie processes.
|
||||
apt-get update -qq &&\
|
||||
apt-get upgrade -yqq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl gnupg tini python3 python3-distutils-extra &&\
|
||||
# Note: tini is a helper for reaping zombie processes.
|
||||
RUN apt-get update -qq \
|
||||
&& apt-get upgrade -yqq \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
|
||||
ca-certificates \
|
||||
tini \
|
||||
# Many users rely on curl for Docker health checks.
|
||||
curl \
|
||||
# Install fonts.
|
||||
# Essential metric-compatible fonts for LibreOffice layout fidelity (replaces MS Fonts).
|
||||
fonts-crosextra-carlito \
|
||||
fonts-crosextra-caladea \
|
||||
fonts-liberation \
|
||||
fonts-liberation2 \
|
||||
# Reliable general-purpose fallback for Chromium.
|
||||
fonts-dejavu \
|
||||
# Unified CJK (Chinese, Japanese, Korean) support.
|
||||
fonts-noto-cjk \
|
||||
# Standard Emoji support.
|
||||
fonts-noto-color-emoji \
|
||||
# Tofu prevention.
|
||||
fonts-noto-core \
|
||||
# Install QPDF & ExifTool (PDF engines).
|
||||
qpdf exiftool \
|
||||
# See https://github.com/nextcloud/docker/issues/380.
|
||||
&& mkdir -p /usr/share/man/man1 \
|
||||
# Cleanup.
|
||||
# Note: the Debian image does automatically a clean after each install thanks to a hook.
|
||||
# Therefore, there is no need for apt-get clean.
|
||||
# See https://stackoverflow.com/a/24417119/3248473.
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
RUN \
|
||||
# Install fonts.
|
||||
# Credits:
|
||||
# https://github.com/arachnys/athenapdf/blob/master/cli/Dockerfile.
|
||||
# https://help.accusoft.com/PrizmDoc/v12.1/HTML/Installing_Asian_Fonts_on_Ubuntu_and_Debian.html.
|
||||
curl -o ./ttf-mscorefonts-installer_3.8.1_all.deb http://httpredir.debian.org/debian/pool/contrib/m/msttcorefonts/ttf-mscorefonts-installer_3.8.1_all.deb &&\
|
||||
apt-get update -qq &&\
|
||||
apt-get upgrade -yqq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
|
||||
./ttf-mscorefonts-installer_3.8.1_all.deb \
|
||||
culmus \
|
||||
fonts-beng \
|
||||
fonts-hosny-amiri \
|
||||
fonts-lklug-sinhala \
|
||||
fonts-lohit-guru \
|
||||
fonts-lohit-knda \
|
||||
fonts-samyak-gujr \
|
||||
fonts-samyak-mlym \
|
||||
fonts-samyak-taml \
|
||||
fonts-sarai \
|
||||
fonts-sil-abyssinica \
|
||||
fonts-sil-padauk \
|
||||
fonts-telu \
|
||||
fonts-thai-tlwg \
|
||||
ttf-wqy-zenhei \
|
||||
fonts-arphic-ukai \
|
||||
fonts-arphic-uming \
|
||||
fonts-ipafont-mincho \
|
||||
fonts-ipafont-gothic \
|
||||
fonts-unfonts-core \
|
||||
# LibreOffice recommends.
|
||||
fonts-crosextra-caladea \
|
||||
fonts-crosextra-carlito \
|
||||
fonts-dejavu \
|
||||
fonts-liberation \
|
||||
fonts-liberation2 \
|
||||
fonts-linuxlibertine \
|
||||
fonts-noto-cjk \
|
||||
fonts-noto-core \
|
||||
fonts-noto-mono \
|
||||
fonts-noto-ui-core \
|
||||
fonts-sil-gentium \
|
||||
fonts-sil-gentium-basic &&\
|
||||
rm -f ./ttf-mscorefonts-installer_3.8.1_all.deb &&\
|
||||
# Add Color and Black-and-White Noto emoji font.
|
||||
# Credits:
|
||||
# https://github.com/gotenberg/gotenberg/pull/325.
|
||||
# https://github.com/googlefonts/noto-emoji.
|
||||
curl -Ls "https://github.com/googlefonts/noto-emoji/raw/$NOTO_COLOR_EMOJI_VERSION/fonts/NotoColorEmoji.ttf" -o /usr/local/share/fonts/NotoColorEmoji.ttf &&\
|
||||
# Cleanup.
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
# 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
|
||||
|
||||
RUN \
|
||||
# Install Hyphenation for LibreOffice.
|
||||
# Credits: https://wiki.archlinux.org/title/LibreOffice.
|
||||
apt-get update -qq &&\
|
||||
apt-get upgrade -yqq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
|
||||
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 &&\
|
||||
# Cleanup.
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
# Support for arbitrary user IDs (OpenShift).
|
||||
# See:
|
||||
# https://github.com/gotenberg/gotenberg/issues/1049.
|
||||
# https://docs.redhat.com/en/documentation/openshift_container_platform/4.15/html/images/creating-images#use-uid_create-images.
|
||||
RUN usermod -aG root gotenberg \
|
||||
&& chgrp -R 0 /home/gotenberg \
|
||||
&& chmod -R g=u /home/gotenberg
|
||||
|
||||
RUN \
|
||||
# Install Chromium.
|
||||
apt-get update -qq &&\
|
||||
apt-get upgrade -yqq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium &&\
|
||||
# Verify installation.
|
||||
chromium --version &&\
|
||||
# Cleanup.
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
# COPY instructions last to maximize cache reuse when only source code changes.
|
||||
|
||||
# Improve fonts subpixel hinting and smoothing.
|
||||
# Credits:
|
||||
# https://github.com/arachnys/athenapdf/issues/69.
|
||||
# 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 the pdfcpu binary (shared across all variants).
|
||||
COPY --link --from=pdfcpu-binary-stage /home/pdfcpu /usr/bin/
|
||||
|
||||
# Set default characterset encoding to UTF-8.
|
||||
# See:
|
||||
@@ -209,79 +184,108 @@ RUN \
|
||||
ENV LANG=C.UTF-8
|
||||
ENV LC_ALL=C.UTF-8
|
||||
|
||||
RUN \
|
||||
# Install LibreOffice & unoconverter. \
|
||||
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 -t trixie-backports libreoffice &&\
|
||||
curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.2.0/unoconv -o /usr/bin/unoconverter &&\
|
||||
chmod +x /usr/bin/unoconverter &&\
|
||||
# unoconverter will look for the Python binary, which has to be at version 3.
|
||||
ln -s /usr/bin/python3 /usr/bin/python &&\
|
||||
# Verify installations.
|
||||
libreoffice --version &&\
|
||||
unoconverter --version &&\
|
||||
# Cleanup.
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
RUN \
|
||||
# Install PDFtk, QPDF & ExifTool (PDF engines).
|
||||
# See https://github.com/gotenberg/gotenberg/pull/273.
|
||||
curl -o /usr/bin/pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" &&\
|
||||
chmod a+x /usr/bin/pdftk-all.jar &&\
|
||||
printf '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk && \
|
||||
chmod +x /usr/bin/pdftk &&\
|
||||
apt-get update -qq &&\
|
||||
apt-get upgrade -yqq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf exiftool &&\
|
||||
# See https://github.com/nextcloud/docker/issues/380.
|
||||
mkdir -p /usr/share/man/man1 &&\
|
||||
# Verify installations.
|
||||
pdftk --version &&\
|
||||
qpdf --version &&\
|
||||
exiftool --version &&\
|
||||
# Cleanup.
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# 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
|
||||
|
||||
# Improve fonts subpixel hinting and smoothing.
|
||||
# Credits:
|
||||
# https://github.com/arachnys/athenapdf/issues/69.
|
||||
# https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d.
|
||||
COPY build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf
|
||||
|
||||
# Copy dictionnaries so that hypens work on Chromium.
|
||||
# See https://github.com/gotenberg/gotenberg/issues/1293.
|
||||
COPY --chown=gotenberg:gotenberg build/chromium-hyphen-data /opt/gotenberg/chromium-hyphen-data
|
||||
|
||||
# Copy the Golang binaries.
|
||||
COPY --from=pdfcpu-binary-stage /home/pdfcpu /usr/bin/
|
||||
COPY --from=gotenberg-binary-stage /home/gotenberg /usr/bin/
|
||||
|
||||
# Environment variables required by modules or else.
|
||||
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
|
||||
ENV CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data
|
||||
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
|
||||
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
|
||||
# Environment variables for PDF engines.
|
||||
ENV PDFTK_BIN_PATH=/usr/bin/pdftk
|
||||
ENV QPDF_BIN_PATH=/usr/bin/qpdf
|
||||
ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool
|
||||
ENV PDFCPU_BIN_PATH=/usr/bin/pdfcpu
|
||||
|
||||
# Capture backing-binary versions at build time so the running process reports
|
||||
# them on traces without spawning the binaries at startup or per request.
|
||||
# See pkg/gotenberg/buildversions.go. Chromium and LibreOffice are captured in
|
||||
# the variant stages below, where they are installed.
|
||||
ENV GOTENBERG_VERSIONS_DIR_PATH=/opt/gotenberg/versions
|
||||
|
||||
COPY --link build/capture-version.sh /opt/gotenberg/capture-version.sh
|
||||
|
||||
RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" qpdf "$QPDF_BIN_PATH" --version \
|
||||
&& bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" exiftool "$EXIFTOOL_BIN_PATH" -ver \
|
||||
&& bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" pdftk "$PDFTK_BIN_PATH" --version \
|
||||
&& bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" pdfcpu "$PDFCPU_BIN_PATH" version
|
||||
|
||||
# OpenTelemetry defaults (noop - no telemetry overhead unless explicitly enabled).
|
||||
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
|
||||
|
||||
# Capture Chromium and LibreOffice versions now that both are installed.
|
||||
RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" chromium "$CHROMIUM_BIN_PATH" --version \
|
||||
&& bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" libreoffice-api "$LIBREOFFICE_BIN_PATH" --version
|
||||
|
||||
USER gotenberg
|
||||
WORKDIR /home/gotenberg
|
||||
|
||||
@@ -290,3 +294,240 @@ 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=
|
||||
|
||||
# Capture the Chromium version now that it is installed.
|
||||
RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" chromium "$CHROMIUM_BIN_PATH" --version
|
||||
|
||||
USER gotenberg
|
||||
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
|
||||
|
||||
# Capture the LibreOffice version now that it is installed.
|
||||
RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" libreoffice-api "$LIBREOFFICE_BIN_PATH" --version
|
||||
|
||||
USER gotenberg
|
||||
WORKDIR /home/gotenberg
|
||||
|
||||
# Default API port.
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT [ "/usr/bin/tini", "--" ]
|
||||
CMD [ "gotenberg" ]
|
||||
|
||||
# ==============================================================
|
||||
# Cloud Run variants
|
||||
# ==============================================================
|
||||
|
||||
# ----------------------------------------------
|
||||
# Cloud Run: full (Chromium + LibreOffice)
|
||||
# ----------------------------------------------
|
||||
FROM gotenberg AS gotenberg-cloudrun
|
||||
|
||||
USER root
|
||||
|
||||
# For security reasons, the non-root user gotenberg does not own the Tini binary by default.
|
||||
# However, some providers like Cloud Run from Google Cloud cannot start a Docker container in that case.
|
||||
# See https://github.com/gotenberg/gotenberg/issues/90#issuecomment-543551353.
|
||||
RUN chown gotenberg: /usr/bin/tini
|
||||
|
||||
ENV API_PORT_FROM_ENV=PORT
|
||||
ENV CHROMIUM_AUTO_START=true
|
||||
ENV LIBREOFFICE_AUTO_START=true
|
||||
ENV WEBHOOK_ENABLE_SYNC_MODE=true
|
||||
ENV GOTENBERG_BUILD_DEBUG_DATA=false
|
||||
ENV LOG_STD_ENABLE_GCP_FIELDS=true
|
||||
|
||||
USER gotenberg
|
||||
|
||||
# ----------------------------------------------
|
||||
# Cloud Run: Chromium only
|
||||
# ----------------------------------------------
|
||||
FROM gotenberg-chromium AS gotenberg-cloudrun-chromium
|
||||
|
||||
USER root
|
||||
|
||||
RUN chown gotenberg: /usr/bin/tini
|
||||
|
||||
ENV API_PORT_FROM_ENV=PORT
|
||||
ENV CHROMIUM_AUTO_START=true
|
||||
ENV WEBHOOK_ENABLE_SYNC_MODE=true
|
||||
ENV GOTENBERG_BUILD_DEBUG_DATA=false
|
||||
ENV LOG_STD_ENABLE_GCP_FIELDS=true
|
||||
|
||||
USER gotenberg
|
||||
|
||||
# ----------------------------------------------
|
||||
# Cloud Run: LibreOffice only
|
||||
# ----------------------------------------------
|
||||
FROM gotenberg-libreoffice AS gotenberg-cloudrun-libreoffice
|
||||
|
||||
USER root
|
||||
|
||||
RUN chown gotenberg: /usr/bin/tini
|
||||
|
||||
ENV API_PORT_FROM_ENV=PORT
|
||||
ENV LIBREOFFICE_AUTO_START=true
|
||||
ENV WEBHOOK_ENABLE_SYNC_MODE=true
|
||||
ENV GOTENBERG_BUILD_DEBUG_DATA=false
|
||||
ENV LOG_STD_ENABLE_GCP_FIELDS=true
|
||||
|
||||
USER gotenberg
|
||||
|
||||
# ==============================================================
|
||||
# AWS Lambda variants
|
||||
# ==============================================================
|
||||
|
||||
# ----------------------------------------------
|
||||
# AWS Lambda: full (Chromium + LibreOffice)
|
||||
# ----------------------------------------------
|
||||
FROM gotenberg AS gotenberg-aws-lambda
|
||||
|
||||
USER root
|
||||
|
||||
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
|
||||
|
||||
ENV AWS_LWA_PORT=3000
|
||||
ENV AWS_LWA_READINESS_CHECK_PATH=/health
|
||||
ENV AWS_LWA_INVOKE_MODE=buffered
|
||||
ENV API_PORT_FROM_ENV=AWS_LWA_PORT
|
||||
ENV WEBHOOK_ENABLE_SYNC_MODE=true
|
||||
ENV GOTENBERG_BUILD_DEBUG_DATA=false
|
||||
|
||||
USER gotenberg
|
||||
|
||||
# ----------------------------------------------
|
||||
# AWS Lambda: Chromium only
|
||||
# ----------------------------------------------
|
||||
FROM gotenberg-chromium AS gotenberg-aws-lambda-chromium
|
||||
|
||||
USER root
|
||||
|
||||
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
|
||||
|
||||
ENV AWS_LWA_PORT=3000
|
||||
ENV AWS_LWA_READINESS_CHECK_PATH=/health
|
||||
ENV AWS_LWA_INVOKE_MODE=buffered
|
||||
ENV API_PORT_FROM_ENV=AWS_LWA_PORT
|
||||
ENV WEBHOOK_ENABLE_SYNC_MODE=true
|
||||
ENV GOTENBERG_BUILD_DEBUG_DATA=false
|
||||
|
||||
USER gotenberg
|
||||
|
||||
# ----------------------------------------------
|
||||
# AWS Lambda: LibreOffice only
|
||||
# ----------------------------------------------
|
||||
FROM gotenberg-libreoffice AS gotenberg-aws-lambda-libreoffice
|
||||
|
||||
USER root
|
||||
|
||||
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
|
||||
|
||||
ENV AWS_LWA_PORT=3000
|
||||
ENV AWS_LWA_READINESS_CHECK_PATH=/health
|
||||
ENV AWS_LWA_INVOKE_MODE=buffered
|
||||
ENV API_PORT_FROM_ENV=AWS_LWA_PORT
|
||||
ENV WEBHOOK_ENABLE_SYNC_MODE=true
|
||||
ENV GOTENBERG_BUILD_DEBUG_DATA=false
|
||||
|
||||
USER gotenberg
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
ARG DOCKER_REGISTRY
|
||||
ARG DOCKER_REPOSITORY
|
||||
ARG GOTENBERG_VERSION
|
||||
|
||||
FROM $DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION
|
||||
|
||||
USER root
|
||||
|
||||
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
|
||||
|
||||
# AWS.
|
||||
ENV AWS_LWA_PORT=3000
|
||||
ENV AWS_LWA_READINESS_CHECK_PATH=/health
|
||||
ENV AWS_LWA_INVOKE_MODE=buffered
|
||||
|
||||
# Gotenberg.
|
||||
ENV API_PORT_FROM_ENV=AWS_LWA_PORT
|
||||
ENV WEBHOOK_ENABLE_SYNC_MODE=true
|
||||
ENV GOTENBERG_BUILD_DEBUG_DATA=false
|
||||
|
||||
USER gotenberg
|
||||
@@ -1,22 +0,0 @@
|
||||
ARG DOCKER_REGISTRY
|
||||
ARG DOCKER_REPOSITORY
|
||||
ARG GOTENBERG_VERSION
|
||||
|
||||
FROM $DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION
|
||||
|
||||
USER root
|
||||
|
||||
# For security reasons, the non-root user gotenberg does not own the Tini binary by default.
|
||||
# However, some providers like Cloud Run from Google Cloud cannot start a Docker container in that case.
|
||||
# See https://github.com/gotenberg/gotenberg/issues/90#issuecomment-543551353.
|
||||
RUN chown gotenberg: /usr/bin/tini
|
||||
|
||||
# Gotenberg.
|
||||
ENV API_PORT_FROM_ENV=PORT
|
||||
ENV CHROMIUM_AUTO_START=true
|
||||
ENV LIBREOFFICE_AUTO_START=true
|
||||
ENV WEBHOOK_ENABLE_SYNC_MODE=true
|
||||
ENV GOTENBERG_BUILD_DEBUG_DATA=false
|
||||
ENV LOG_ENABLE_GCP_FIELDS=true
|
||||
|
||||
USER gotenberg
|
||||
34
build/capture-version.sh
Normal file
34
build/capture-version.sh
Normal file
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Captures the version of a backing binary into a per-module file that the
|
||||
# running Gotenberg process reads via gotenberg.BuildVersion, so it never spawns
|
||||
# the binary just to report a version. This keeps cold start and the first
|
||||
# request cheap, which matters on serverless platforms.
|
||||
#
|
||||
# Failure-tolerant by design: a probe that errors writes an empty file, and the
|
||||
# runtime falls back to detecting the version live. A failing probe must never
|
||||
# fail the image build.
|
||||
#
|
||||
# Usage: capture-version.sh <output-dir> <module-id> <bin> [args...]
|
||||
set -u
|
||||
|
||||
dir="$1"
|
||||
id="$2"
|
||||
shift 2
|
||||
|
||||
mkdir -p "$dir"
|
||||
|
||||
# Run the probe once. On failure, keep going with empty output.
|
||||
raw="$("$@" 2>/dev/null)" || raw=""
|
||||
|
||||
case "$id" in
|
||||
pdfcpu)
|
||||
# pdfcpu prints "pdfcpu: <version>"; keep only the part the runtime parser
|
||||
# keeps so the recorded value matches the live-detection fallback.
|
||||
version="$(printf '%s\n' "$raw" | grep -m1 '^pdfcpu:' | sed 's/^pdfcpu:[[:space:]]*//')"
|
||||
;;
|
||||
*)
|
||||
version="$(printf '%s\n' "$raw" | head -n1)"
|
||||
;;
|
||||
esac
|
||||
|
||||
printf '%s' "$version" | tr -d '\r' >"$dir/$id"
|
||||
11
cmd/gotenberg-chromium/main.go
Normal file
11
cmd/gotenberg-chromium/main.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
gotenbergcmd "github.com/gotenberg/gotenberg/v8/cmd"
|
||||
// Gotenberg modules (Chromium variant — no LibreOffice).
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/standard/chromium"
|
||||
)
|
||||
|
||||
func main() {
|
||||
gotenbergcmd.Run()
|
||||
}
|
||||
11
cmd/gotenberg-libreoffice/main.go
Normal file
11
cmd/gotenberg-libreoffice/main.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
gotenbergcmd "github.com/gotenberg/gotenberg/v8/cmd"
|
||||
// Gotenberg modules (LibreOffice variant — no Chromium).
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/standard/libreoffice"
|
||||
)
|
||||
|
||||
func main() {
|
||||
gotenbergcmd.Run()
|
||||
}
|
||||
@@ -49,6 +49,7 @@ func Run() {
|
||||
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")
|
||||
fs.String("log-std-level-case", gotenberg.LowerLevelCase, "Set the case of the level field in the standard output, either lower or upper")
|
||||
|
||||
// Deprecated logging flags.
|
||||
fs.String("log-format", gotenberg.AutoLoggingFormat, "Set the log format")
|
||||
@@ -123,6 +124,7 @@ func Run() {
|
||||
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"),
|
||||
LogStdLevelCase: parsedFlags.MustString("log-std-level-case"),
|
||||
}
|
||||
// LogLevel uses its own flag, not the format flag.
|
||||
telemetryCfg.LogLevel = parsedFlags.MustString("log-level")
|
||||
|
||||
22
compose.yaml
22
compose.yaml
@@ -21,6 +21,8 @@ services:
|
||||
- "--api-port=${API_PORT}"
|
||||
- "--api-port-from-env=${API_PORT_FROM_ENV}"
|
||||
- "--api-bind-ip=${API_BIND_IP}"
|
||||
- "--api-tls-cert-file=${API_TLS_CERT_FILE}"
|
||||
- "--api-tls-key-file=${API_TLS_KEY_FILE}"
|
||||
- "--api-start-timeout=${API_START_TIMEOUT}"
|
||||
- "--api-timeout=${API_TIMEOUT}"
|
||||
- "--api-body-limit=${API_BODY_LIMIT}"
|
||||
@@ -29,6 +31,9 @@ services:
|
||||
- "--api-enable-basic-auth=${API_ENABLE_BASIC_AUTH}"
|
||||
- "--api-download-from-allow-list=${API_DOWNLOAD_FROM_ALLOW_LIST}"
|
||||
- "--api-download-from-deny-list=${API_DOWNLOAD_FROM_DENY_LIST}"
|
||||
- "--api-download-from-deny-private-ips=${API_DOWNLOAD_FROM_DENY_PRIVATE_IPS}"
|
||||
- "--api-download-from-deny-public-ips=${API_DOWNLOAD_FROM_DENY_PUBLIC_IPS}"
|
||||
- "--api-download-from-enable-environment-proxy=${API_DOWNLOAD_FROM_ENABLE_ENVIRONMENT_PROXY}"
|
||||
- "--api-download-from-max-retry=${API_DOWNLOAD_FROM_MAX_RETRY}"
|
||||
- "--api-disable-download-from=${API_DISABLE_DOWNLOAD_FROM}"
|
||||
- "--api-disable-health-check-route-telemetry=${API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY}"
|
||||
@@ -48,8 +53,11 @@ services:
|
||||
- "--chromium-allow-file-access-from-files=${CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES}"
|
||||
- "--chromium-host-resolver-rules=${CHROMIUM_HOST_RESOLVER_RULES}"
|
||||
- "--chromium-proxy-server=${CHROMIUM_PROXY_SERVER}"
|
||||
- "--chromium-enable-environment-proxy=${CHROMIUM_ENABLE_ENVIRONMENT_PROXY}"
|
||||
- "--chromium-allow-list=${CHROMIUM_ALLOW_LIST}"
|
||||
- "--chromium-deny-list=${CHROMIUM_DENY_LIST}"
|
||||
- "--chromium-deny-private-ips=${CHROMIUM_DENY_PRIVATE_IPS}"
|
||||
- "--chromium-deny-public-ips=${CHROMIUM_DENY_PUBLIC_IPS}"
|
||||
- "--chromium-clear-cache=${CHROMIUM_CLEAR_CACHE}"
|
||||
- "--chromium-clear-cookies=${CHROMIUM_CLEAR_COOKIES}"
|
||||
- "--chromium-disable-javascript=${CHROMIUM_DISABLE_JAVASCRIPT}"
|
||||
@@ -59,11 +67,17 @@ services:
|
||||
- "--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-deny-private-ips=${LIBREOFFICE_DENY_PRIVATE_IPS}"
|
||||
- "--libreoffice-deny-public-ips=${LIBREOFFICE_DENY_PUBLIC_IPS}"
|
||||
- "--libreoffice-enable-environment-proxy=${LIBREOFFICE_ENABLE_ENVIRONMENT_PROXY}"
|
||||
- "--libreoffice-disable-routes=${LIBREOFFICE_DISABLE_ROUTES}"
|
||||
- "--log-level=${LOG_LEVEL}"
|
||||
- "--log-fields-prefix=${LOG_FIELDS_PREFIX}"
|
||||
- "--log-std-format=${LOG_STD_FORMAT}"
|
||||
- "--log-std-enable-gcp-fields=${LOG_STD_ENABLE_GCP_FIELDS}"
|
||||
- "--log-std-level-case=${LOG_STD_LEVEL_CASE}"
|
||||
- "--pdfengines-merge-engines=${PDFENGINES_MERGE_ENGINES}"
|
||||
- "--pdfengines-split-engines=${PDFENGINES_SPLIT_ENGINES}"
|
||||
- "--pdfengines-flatten-engines=${PDFENGINES_FLATTEN_ENGINES}"
|
||||
@@ -77,18 +91,20 @@ services:
|
||||
- "--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-factur-x-engines=${PDFENGINES_FACTUR_X_ENGINES}"
|
||||
- "--pdfengines-disable-routes=${PDFENGINES_DISABLE_ROUTES}"
|
||||
- "--prometheus-namespace=${PROMETHEUS_NAMESPACE}"
|
||||
- "--prometheus-collect-interval=${PROMETHEUS_COLLECT_INTERVAL}"
|
||||
- "--prometheus-disable-route-logging=${PROMETHEUS_DISABLE_ROUTE_LOGGING}"
|
||||
- "--prometheus-disable-route-telemetry=${PROMETHEUS_DISABLE_ROUTE_TELEMETRY}"
|
||||
- "--prometheus-disable-collect=${PROMETHEUS_DISABLE_COLLECT}"
|
||||
- "--prometheus-metrics-path=${PROMETHEUS_METRICS_PATH}"
|
||||
- "--webhook-enable-sync-mode=${WEBHOOK_ENABLE_SYNC_MODE}"
|
||||
- "--webhook-allow-list=${WEBHOOK_ALLOW_LIST}"
|
||||
- "--webhook-deny-list=${WEBHOOK_DENY_LIST}"
|
||||
- "--webhook-error-allow-list=${WEBHOOK_ERROR_ALLOW_LIST}"
|
||||
- "--webhook-error-deny-list=${WEBHOOK_ERROR_DENY_LIST}"
|
||||
- "--webhook-deny-private-ips=${WEBHOOK_DENY_PRIVATE_IPS}"
|
||||
- "--webhook-deny-public-ips=${WEBHOOK_DENY_PUBLIC_IPS}"
|
||||
- "--webhook-enable-environment-proxy=${WEBHOOK_ENABLE_ENVIRONMENT_PROXY}"
|
||||
- "--webhook-max-retry=${WEBHOOK_MAX_RETRY}"
|
||||
- "--webhook-retry-min-wait=${WEBHOOK_RETRY_MIN_WAIT}"
|
||||
- "--webhook-retry-max-wait=${WEBHOOK_RETRY_MAX_WAIT}"
|
||||
|
||||
137
go.mod
137
go.mod
@@ -1,41 +1,40 @@
|
||||
module github.com/gotenberg/gotenberg/v8
|
||||
|
||||
go 1.26.0
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/alexliesenfeld/health v0.8.1
|
||||
github.com/barasher/go-exiftool v1.10.0
|
||||
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc
|
||||
github.com/chromedp/chromedp v0.15.1
|
||||
github.com/cucumber/godog v0.15.1
|
||||
github.com/dlclark/regexp2 v1.11.5
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
github.com/docker/go-connections v0.6.0
|
||||
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
|
||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d // pinned with chromedp v0.14.2, see below
|
||||
github.com/chromedp/chromedp v0.14.2 // pinned: v0.15.x breaks the headless print-mode paint pipeline (rAF / ResizeObserver / IntersectionObserver stop firing, blank charts). See https://github.com/gotenberg/gotenberg/issues/1535.
|
||||
github.com/cucumber/godog v0.16.0
|
||||
github.com/dlclark/regexp2 v1.12.0
|
||||
github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8
|
||||
github.com/labstack/echo/v4 v4.15.1
|
||||
github.com/labstack/gommon v0.4.2
|
||||
github.com/labstack/echo/v4 v4.15.4
|
||||
github.com/labstack/gommon v0.5.0
|
||||
github.com/mholt/archives v0.1.5
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/shirou/gopsutil/v4 v4.26.2
|
||||
github.com/moby/moby/api v1.55.0
|
||||
github.com/moby/moby/client v0.5.1
|
||||
github.com/prometheus/client_golang v1.24.1
|
||||
github.com/shirou/gopsutil/v4 v4.26.7
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/testcontainers/testcontainers-go v0.41.0
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.17.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
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/term v0.41.0
|
||||
golang.org/x/text v0.35.0
|
||||
github.com/testcontainers/testcontainers-go v0.43.0
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0
|
||||
go.opentelemetry.io/otel v1.45.0
|
||||
go.opentelemetry.io/otel/log v0.20.0
|
||||
go.opentelemetry.io/otel/metric v1.45.0
|
||||
go.opentelemetry.io/otel/sdk v1.45.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.45.0
|
||||
go.opentelemetry.io/otel/trace v1.45.0
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/term v0.45.0
|
||||
golang.org/x/text v0.40.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -43,11 +42,11 @@ require (
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/STARRY-S/zip v0.2.3 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bodgit/plumbing v1.3.0 // indirect
|
||||
github.com/bodgit/sevenzip v1.6.1 // indirect
|
||||
github.com/bodgit/sevenzip v1.6.4 // indirect
|
||||
github.com/bodgit/windows v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
@@ -58,89 +57,87 @@ require (
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect
|
||||
github.com/cucumber/messages/go/v21 v21.0.1 // indirect
|
||||
github.com/cucumber/gherkin/go/v42 v42.0.0 // indirect
|
||||
github.com/cucumber/messages/go/v34 v34.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.7.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/ebitengine/purego v0.10.2 // indirect
|
||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6 // indirect
|
||||
github.com/go-logr/logr v1.4.4 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/gofrs/uuid v4.4.0+incompatible // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
||||
github.com/hashicorp/go-memdb v1.3.5 // indirect
|
||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/compress v1.19.1 // indirect
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
||||
github.com/minio/minlz v1.1.0 // indirect
|
||||
github.com/minio/minlz v1.1.1 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.2.0 // indirect
|
||||
github.com/moby/patternmatcher v0.6.1 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
github.com/moby/sys/sequential v0.7.0 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/morikuni/aec v1.1.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nwaples/rardecode/v2 v2.2.2 // indirect
|
||||
github.com/nwaples/rardecode/v2 v2.2.5 // 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/pkg/errors v0.9.1 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.27 // 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_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/common v0.70.1 // indirect
|
||||
github.com/prometheus/otlptranslator v1.0.0 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/sorairolake/lzip-go v0.3.8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/stangelandcl/ppmd v0.1.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.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/contrib/bridges/prometheus v0.69.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 // indirect
|
||||
google.golang.org/grpc v1.82.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
306
go.sum
306
go.sum
@@ -10,18 +10,16 @@ github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
|
||||
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
|
||||
github.com/alexliesenfeld/health v0.8.1 h1:wdE3vt+cbJotiR8DGDBZPKHDFoJbAoWEfQTcqrmedUg=
|
||||
github.com/alexliesenfeld/health v0.8.1/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEXNMpIhs=
|
||||
github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
|
||||
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
|
||||
github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4=
|
||||
github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8=
|
||||
github.com/bodgit/sevenzip v1.6.4 h1:iHiVJfxbrB6RF4X+snI2MpVgNBKmVfGaTqZGNlMQIU0=
|
||||
github.com/bodgit/sevenzip v1.6.4/go.mod h1:ZtNi5KNgHXeXg1G7WiF0IWSuFE2eG6lt/cTGlvuirO0=
|
||||
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
|
||||
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
@@ -30,10 +28,10 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=
|
||||
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
|
||||
github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
|
||||
github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4=
|
||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU=
|
||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
|
||||
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
|
||||
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
|
||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
@@ -46,43 +44,39 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
||||
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/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=
|
||||
github.com/cucumber/godog v0.15.1/go.mod h1:qju+SQDewOljHuq9NSM66s0xEhogx0q30flfxL4WUk8=
|
||||
github.com/cucumber/messages/go/v21 v21.0.1 h1:wzA0LxwjlWQYZd32VTlAVDTkW6inOFmSM+RuOwHZiMI=
|
||||
github.com/cucumber/messages/go/v21 v21.0.1/go.mod h1:zheH/2HS9JLVFukdrsPWoPdmUtmYQAQPLk7w5vWsk5s=
|
||||
github.com/cucumber/messages/go/v22 v22.0.0/go.mod h1:aZipXTKc0JnjCsXrJnuZpWhtay93k7Rn3Dee7iyPJjs=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/cucumber/gherkin/go/v42 v42.0.0 h1:Ulh3E2awUUSSja+wonP/IOQ+ycmiZwZbgmzqk5H8JNI=
|
||||
github.com/cucumber/gherkin/go/v42 v42.0.0/go.mod h1:CsaumaO2dR9XvBc6ZyiGLMhWCKtTRDxgoxqJigSjSSg=
|
||||
github.com/cucumber/godog v0.16.0 h1:ezQbgItuWqZrjPUQwLJ3muwIlvzXBOfZso5QZfG7efE=
|
||||
github.com/cucumber/godog v0.16.0/go.mod h1:EDUX9yCqANK+GpbftMDeu61sUDtdLuo1JJgXD2n3bbM=
|
||||
github.com/cucumber/messages/go/v34 v34.2.0 h1:VCbcNOMz+f8ccjjOOx1NLBNhwvE7/X49Atc8klJa+i8=
|
||||
github.com/cucumber/messages/go/v34 v34.2.0/go.mod h1:LYUPjqlTS1kS0pdkdf6sS5uirnjwiIzEGyXPezXNhL8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
|
||||
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
||||
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
|
||||
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
|
||||
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
|
||||
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
||||
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
||||
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6 h1:nxP4pPoyqOAgX8lYDFCfl3DyKeXErCvSvhcyzwGV9CE=
|
||||
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
@@ -94,14 +88,10 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
|
||||
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76 h1:Ltt9ldIaSYEsjA7sPY2c8r9dOmnKM1vlzhh3dxlhBHM=
|
||||
github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
@@ -109,16 +99,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||
github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
|
||||
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||
github.com/hashicorp/go-memdb v1.3.4/go.mod h1:uBTr1oQbtuMgd1SSGoR8YV27eT3sBHbYiNm53bMpgSg=
|
||||
github.com/hashicorp/go-memdb v1.3.5 h1:b3taDMxCBCBVgyRrS1AZVHO14ubMYZB++QpNhBg+Nyo=
|
||||
github.com/hashicorp/go-memdb v1.3.5/go.mod h1:8IVKKBkVe+fxFgdFOYxzQQNjz+sWCyHCdIC/+5+Vy1Y=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
|
||||
@@ -132,124 +120,114 @@ github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iP
|
||||
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
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.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
||||
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs=
|
||||
github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs=
|
||||
github.com/labstack/echo/v4 v4.15.4/go.mod h1:CuMetKIRwsuO/qlAgMq+KTAalwGoB/h4tC+yPdrTj1g=
|
||||
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
|
||||
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 h1:Qj3hTcdWH8uMZDI41HNuTuJN525C7NBrbtH5kSO6fPk=
|
||||
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
|
||||
github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0=
|
||||
github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
|
||||
github.com/minio/minlz v1.1.0 h1:rUOGu3EP4EqJC5k3qCsIwEnZiJULKqtRyDdqbhlvMmQ=
|
||||
github.com/minio/minlz v1.1.0/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
|
||||
github.com/minio/minlz v1.1.1 h1:OGmft1V6AnI/Wme332U6bhG54nxEan+VFgkD7lat4KM=
|
||||
github.com/minio/minlz v1.1.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
||||
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
||||
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw=
|
||||
github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM=
|
||||
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||
github.com/moby/patternmatcher v0.6.1/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/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
|
||||
github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
|
||||
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nwaples/rardecode/v2 v2.2.2 h1:/5oL8dzYivRM/tqX9VcTSWfbpwcbwKG1QtSJr3b3KcU=
|
||||
github.com/nwaples/rardecode/v2 v2.2.2/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
|
||||
github.com/nwaples/rardecode/v2 v2.2.5 h1:L5doqgGfQwI7qADJMqnkrSB86rpPsqQDrHeO0HWa5JY=
|
||||
github.com/nwaples/rardecode/v2 v2.2.5/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
|
||||
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
|
||||
github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
||||
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
|
||||
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
|
||||
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
|
||||
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/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.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI=
|
||||
github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
|
||||
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
|
||||
github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stangelandcl/ppmd v0.1.1 h1:c25QazhlWUn5nmR1QOzafKhQxBicAr7GGCKER2aJ8H8=
|
||||
github.com/stangelandcl/ppmd v0.1.1/go.mod h1:Rrv7M+/2P5jYr/GMLhBl7Ug3uJ1bUiVzr5LbbaV6xgY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/testcontainers/testcontainers-go v0.41.0 h1:mfpsD0D36YgkxGj2LrIyxuwQ9i2wCKAD+ESsYM1wais=
|
||||
github.com/testcontainers/testcontainers-go v0.41.0/go.mod h1:pdFrEIfaPl24zmBjerWTTYaY0M6UHsqA1YSvsoU40MI=
|
||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/testcontainers/testcontainers-go v0.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A=
|
||||
github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo=
|
||||
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
||||
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
||||
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
@@ -263,52 +241,54 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
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.17.0 h1:NFIS6x7wyObQ7cR84x7bt1sr8nYBx89s3x3GwRjw40k=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0/go.mod h1:39SaByOyDMRMe872AE7uelMuQZidIw7LLFAnQi0FWTE=
|
||||
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.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
|
||||
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/contrib/bridges/otelslog v0.19.0 h1:5RgvxieNq9tS3ewrV1vnODvbHPfKUIJcYtF9Cvz+6aQ=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0/go.mod h1:iTBIdNwx/xmUhfgJs6+84S4dIK059811cO1eUBjKcHY=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0 h1:saQoWg5845Q8TojpqeVStS7zGwVZ6bc5W2PJavTPiBM=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0/go.mod h1:AAaS6xs5AyqMdR3Ir0nSWK+QudL2XM8Vbw5INzUxNc8=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0 h1:R3jsCoTIzv0BiYNhW0axyswn/6SMJ8xL1OuGxvni1Kw=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0/go.mod h1:m07gqyr2QhQxKOKb5vqKCCBtLH3uqlNYR7PU/FISXVU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
|
||||
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
|
||||
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0/go.mod h1:MP4eemTiI9zC8fgg+DYynhYDYf3ba72S376TvP+Ye0Q=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0 h1:vkrK8PAznv2NKt2r+kdu252ccGzkEqLc2aSXbQIALYQ=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0/go.mod h1:V/UB6D3vMF/UBOL5igAsAYnk1nG/bzYYTzvsB16cy7o=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 h1:aZfdmtI6QU/DAPD4b7YZ5zuJgewxO1EW9miOZklqleU=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0/go.mod h1:isNl10/Om5CBWu9jj8WOb2+tJLbCVXDgqwzCaJMnJ6w=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0=
|
||||
go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs=
|
||||
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E=
|
||||
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
|
||||
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
|
||||
go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns=
|
||||
go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI=
|
||||
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
|
||||
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY=
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
|
||||
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
|
||||
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
@@ -317,34 +297,34 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 h1:g0RAkxK/smSu/iRwC/KIX1mwUoVJtk2OjbgaeS4DmUM=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 h1:9HZDLIdYBJXAnaFOr9WHrKVycfpY+75s9HGadC0305A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -355,3 +335,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||
|
||||
107
package-lock.json
generated
107
package-lock.json
generated
@@ -5,9 +5,9 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"prettier": "3.8.1",
|
||||
"prettier": "3.9.6",
|
||||
"prettier-plugin-gherkin": "^3.1.3",
|
||||
"prettier-plugin-sh": "^0.18.0"
|
||||
"prettier-plugin-sh": "^0.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cucumber/gherkin": {
|
||||
@@ -34,15 +34,80 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@reteps/dockerfmt": {
|
||||
"version": "0.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.3.6.tgz",
|
||||
"integrity": "sha512-Tb5wIMvBf/nLejTQ61krK644/CEMB/cpiaIFXqGApfGqO3GwcR3qnI0DbmkFVCl2OyEp8LnLX3EkucoL0+tbFg==",
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.5.4.tgz",
|
||||
"integrity": "sha512-HEGgXVVOb+JtGUSSzXl/XPKFIZjMDTUoHarCjaQdkY+cb5M9K/O3b5xm+x0IPIk3SfHurbc0bSgcFsQlzjitxA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"dockerfmt": "dist/launcher.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^v12.20.0 || ^14.13.0 || >=16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@reteps/dockerfmt-darwin-arm64": "0.5.4",
|
||||
"@reteps/dockerfmt-darwin-x64": "0.5.4",
|
||||
"@reteps/dockerfmt-linux-arm64": "0.5.4",
|
||||
"@reteps/dockerfmt-linux-x64": "0.5.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@reteps/dockerfmt-darwin-arm64": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt-darwin-arm64/-/dockerfmt-darwin-arm64-0.5.4.tgz",
|
||||
"integrity": "sha512-urMqV+dQyvVI8/WrXwClX9e1PEyS35wFdwJjpZYmL09AkV4Io5U1oam8UBKK7jZk0+YsdF88ay6e86Kn6DIyQg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@reteps/dockerfmt-darwin-x64": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt-darwin-x64/-/dockerfmt-darwin-x64-0.5.4.tgz",
|
||||
"integrity": "sha512-fJORy6DFxbgDiMqxpLTPZlb5KUY0Vq0iR4NGnyKnuYZ9LdZUS508DK2kt/AJ87/jIKNV1qRG0JXG1Tc6xdvWjw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@reteps/dockerfmt-linux-arm64": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt-linux-arm64/-/dockerfmt-linux-arm64-0.5.4.tgz",
|
||||
"integrity": "sha512-6pVakO06eXtDuvxy1Dnjs/gQyUoGGycle8PRSt5IFRwLi/AVaOQwfkfmW0WP8VH9wNUeti6BfJ3ksTn2G+XMxg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@reteps/dockerfmt-linux-x64": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt-linux-x64/-/dockerfmt-linux-x64-0.5.4.tgz",
|
||||
"integrity": "sha512-OD6SIlUV1D4TgJoTui3FMBAZsGbTSPYsiT0BKhD6jMUcJb3GpFTa7dY9rL8rP9FUqfL7OTHVUGUOL4Rh64Olog==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||
@@ -58,9 +123,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||
"version": "3.9.6",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
|
||||
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -86,14 +151,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier-plugin-sh": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.18.0.tgz",
|
||||
"integrity": "sha512-cW1XL27FOJQ/qGHOW6IHwdCiNWQsAgK+feA8V6+xUTaH0cD3Mh+tFAtBvEEWvuY6hTDzRV943Fzeii+qMOh7nQ==",
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.19.0.tgz",
|
||||
"integrity": "sha512-39VXFZH/cOGtcuu8aeSvqp/hhwomOR4QroZUj+jBz2cNb3os9s0sqFZSNlYts6jdtLLDU7D2YT3Z1+abtb7adQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reteps/dockerfmt": "^0.3.6",
|
||||
"sh-syntax": "^0.5.8"
|
||||
"@reteps/dockerfmt": "^0.5.4",
|
||||
"sh-syntax": "^0.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
@@ -113,14 +178,11 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/sh-syntax": {
|
||||
"version": "0.5.8",
|
||||
"resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.5.8.tgz",
|
||||
"integrity": "sha512-JfVoxf4FxQI5qpsPbkHhZo+n6N9YMJobyl4oGEUBb/31oQYlgTjkXQD8PBiafS2UbWoxrTO0Z5PJUBXEPAG1Zw==",
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.6.0.tgz",
|
||||
"integrity": "sha512-52VK6z/cdZHv7UURjIcwfBUQZrAhIEEe0bY4lrkfypjnFIKsDZdD3Uaz/dBiw/sF8BeX0Mssv140s8EnrsJ9dQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
@@ -128,13 +190,6 @@
|
||||
"url": "https://opencollective.com/sh-syntax"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"prettier": "3.8.1",
|
||||
"prettier": "3.9.6",
|
||||
"prettier-plugin-gherkin": "^3.1.3",
|
||||
"prettier-plugin-sh": "^0.18.0"
|
||||
"prettier-plugin-sh": "^0.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
53
pkg/gotenberg/attrs.go
Normal file
53
pkg/gotenberg/attrs.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// maxAttrRunes bounds the length of a string span attribute to keep payload
|
||||
// size and backend cardinality in check.
|
||||
const maxAttrRunes = 256
|
||||
|
||||
// CapAttr truncates s to at most [maxAttrRunes] runes, appending an ellipsis
|
||||
// when it shortens the value. It is multibyte-safe.
|
||||
func CapAttr(s string) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= maxAttrRunes {
|
||||
return s
|
||||
}
|
||||
return string(runes[:maxAttrRunes-1]) + "…"
|
||||
}
|
||||
|
||||
// RedactURL parses raw and returns a redacted, length-capped form safe to use
|
||||
// as a span attribute or event value. Userinfo, query, and fragment are
|
||||
// dropped because they may carry credentials or other sensitive data. It
|
||||
// returns an empty string when raw is empty or cannot be parsed.
|
||||
func RedactURL(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
parsed.User = nil
|
||||
parsed.RawQuery = ""
|
||||
parsed.ForceQuery = false
|
||||
parsed.Fragment = ""
|
||||
parsed.RawFragment = ""
|
||||
|
||||
return CapAttr(parsed.String())
|
||||
}
|
||||
|
||||
// MapEnum returns value when it belongs to allowed, otherwise "other". It keeps
|
||||
// a span attribute or metric dimension bounded even when an upstream tool
|
||||
// introduces a new enum value.
|
||||
func MapEnum(value string, allowed ...string) string {
|
||||
if slices.Contains(allowed, value) {
|
||||
return value
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
62
pkg/gotenberg/attrs_test.go
Normal file
62
pkg/gotenberg/attrs_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRedactURL(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"strips userinfo query fragment", "https://user:pass@example.com/path?token=secret#frag", "https://example.com/path"},
|
||||
{"keeps host and path", "http://example.com/a/b", "http://example.com/a/b"},
|
||||
{"parse error", "http://example.com/%zz", ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := RedactURL(tc.raw); got != tc.want {
|
||||
t.Errorf("RedactURL(%q) = %q, want %q", tc.raw, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactURL_Caps(t *testing.T) {
|
||||
raw := "https://example.com/" + strings.Repeat("a", 400)
|
||||
got := RedactURL(raw)
|
||||
if n := len([]rune(got)); n != maxAttrRunes {
|
||||
t.Errorf("expected capped length %d, got %d", maxAttrRunes, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapAttr(t *testing.T) {
|
||||
t.Run("short unchanged", func(t *testing.T) {
|
||||
if got := CapAttr("short"); got != "short" {
|
||||
t.Errorf("expected unchanged, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multibyte truncated to rune cap", func(t *testing.T) {
|
||||
got := CapAttr(strings.Repeat("é", 400))
|
||||
if n := len([]rune(got)); n != maxAttrRunes {
|
||||
t.Errorf("expected %d runes, got %d", maxAttrRunes, n)
|
||||
}
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Error("expected an ellipsis suffix on a truncated value")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMapEnum(t *testing.T) {
|
||||
allowed := []string{"document", "stylesheet", "script"}
|
||||
|
||||
if got := MapEnum("script", allowed...); got != "script" {
|
||||
t.Errorf("expected member passthrough, got %q", got)
|
||||
}
|
||||
if got := MapEnum("websocket", allowed...); got != "other" {
|
||||
t.Errorf("expected non-member to map to other, got %q", got)
|
||||
}
|
||||
}
|
||||
50
pkg/gotenberg/buildversions.go
Normal file
50
pkg/gotenberg/buildversions.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BuildVersionsDirPathEnvVar names the environment variable holding the
|
||||
// absolute path to a directory of build-time version files. The Gotenberg image
|
||||
// writes one file per module there, named by module ID and holding the version
|
||||
// string of that module's backing binary, captured right after the binary is
|
||||
// installed. The running process reads these files instead of executing the
|
||||
// binaries, which keeps startup and the first request cheap.
|
||||
const BuildVersionsDirPathEnvVar = "GOTENBERG_VERSIONS_DIR_PATH"
|
||||
|
||||
// BuildVersion returns the build-time version captured for the module with the
|
||||
// given ID. The boolean is false when no version was captured, which is the
|
||||
// case for local or non-Docker builds where the directory is absent. A module
|
||||
// uses it to avoid spawning its backing binary just to report a version.
|
||||
//
|
||||
// It is defensive: an unset variable, a missing or unreadable file, or an empty
|
||||
// value all yield ("", false), so the caller falls back to detecting the
|
||||
// version at runtime. See [BuildVersionsDirPathEnvVar].
|
||||
func BuildVersion(moduleID string) (string, bool) {
|
||||
dir := os.Getenv(BuildVersionsDirPathEnvVar)
|
||||
if dir == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Module IDs are fixed internal constants, never paths. Guard anyway so a
|
||||
// stray separator can't escape the versions directory.
|
||||
if moduleID != filepath.Base(moduleID) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// The directory comes from a trusted operator-set environment variable,
|
||||
// mirroring how engines exec their env-configured binaries.
|
||||
b, err := os.ReadFile(filepath.Join(dir, moduleID)) //nolint:gosec
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
version := strings.TrimSpace(string(b))
|
||||
if version == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return version, true
|
||||
}
|
||||
81
pkg/gotenberg/buildversions_test.go
Normal file
81
pkg/gotenberg/buildversions_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildVersion(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
fileBody string
|
||||
writeFile bool
|
||||
setEnv bool
|
||||
moduleID string
|
||||
wantValue string
|
||||
wantOk bool
|
||||
}{
|
||||
{
|
||||
scenario: "version present",
|
||||
fileBody: "Chromium 146.0",
|
||||
writeFile: true,
|
||||
setEnv: true,
|
||||
moduleID: "chromium",
|
||||
wantValue: "Chromium 146.0",
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
scenario: "value trimmed",
|
||||
fileBody: " qpdf version 11.9.0 \n",
|
||||
writeFile: true,
|
||||
setEnv: true,
|
||||
moduleID: "qpdf",
|
||||
wantValue: "qpdf version 11.9.0",
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
scenario: "empty file falls back",
|
||||
fileBody: " \n",
|
||||
writeFile: true,
|
||||
setEnv: true,
|
||||
moduleID: "pdftk",
|
||||
wantValue: "",
|
||||
wantOk: false,
|
||||
},
|
||||
{
|
||||
scenario: "missing file falls back",
|
||||
writeFile: false,
|
||||
setEnv: true,
|
||||
moduleID: "exiftool",
|
||||
wantValue: "",
|
||||
wantOk: false,
|
||||
},
|
||||
{
|
||||
scenario: "env unset falls back",
|
||||
setEnv: false,
|
||||
moduleID: "chromium",
|
||||
wantValue: "",
|
||||
wantOk: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.setEnv {
|
||||
dir := t.TempDir()
|
||||
if tc.writeFile {
|
||||
if err := os.WriteFile(filepath.Join(dir, tc.moduleID), []byte(tc.fileBody), 0o600); err != nil {
|
||||
t.Fatalf("write version file: %v", err)
|
||||
}
|
||||
}
|
||||
t.Setenv(BuildVersionsDirPathEnvVar, dir)
|
||||
} else {
|
||||
t.Setenv(BuildVersionsDirPathEnvVar, "")
|
||||
}
|
||||
|
||||
value, ok := BuildVersion(tc.moduleID)
|
||||
if value != tc.wantValue || ok != tc.wantOk {
|
||||
t.Errorf("BuildVersion(%q) = (%q, %t), want (%q, %t)", tc.moduleID, value, ok, tc.wantValue, tc.wantOk)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,14 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// Cmd wraps an [exec.Cmd].
|
||||
@@ -55,6 +61,13 @@ func CommandContext(ctx context.Context, logger *slog.Logger, binPath string, ar
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetEnv replaces the environment variables passed to the underlying
|
||||
// process. When SetEnv is not called, the process inherits the parent's
|
||||
// environment.
|
||||
func (cmd *Cmd) SetEnv(env []string) {
|
||||
cmd.process.Env = env
|
||||
}
|
||||
|
||||
// Start starts the command but does not wait for its completion.
|
||||
func (cmd *Cmd) Start() error {
|
||||
err := cmd.pipeOutput()
|
||||
@@ -85,11 +98,44 @@ func (cmd *Cmd) Wait() error {
|
||||
|
||||
// Exec executes the command and waits for its completion or until the context
|
||||
// is done. In any case, it kills the unix process and all its children.
|
||||
//
|
||||
// When the context carries an active trace span, Exec records a
|
||||
// "process.exec" client span around the execution. It is the single
|
||||
// instrumentation point for every short-lived external binary (soffice, pdftk,
|
||||
// qpdf, exiftool, pdfcpu). The span is skipped when there is no active parent,
|
||||
// so process starts performed off the request path do not emit orphan roots.
|
||||
func (cmd *Cmd) Exec() (int, error) {
|
||||
if cmd.ctx == nil {
|
||||
return 10, errors.New("nil context")
|
||||
}
|
||||
|
||||
var span trace.Span
|
||||
if trace.SpanContextFromContext(cmd.ctx).IsValid() {
|
||||
_, span = Tracer().Start(cmd.ctx, "process.exec",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ProcessExecutableName(filepath.Base(cmd.process.Path))),
|
||||
)
|
||||
defer span.End()
|
||||
}
|
||||
|
||||
code, err := cmd.exec()
|
||||
|
||||
if span != nil {
|
||||
span.SetAttributes(attribute.Int("process.exit.code", code))
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
span.SetAttributes(semconv.ErrorTypeKey.String(execErrorType(cmd.ctx)))
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
}
|
||||
}
|
||||
|
||||
return code, err
|
||||
}
|
||||
|
||||
// exec runs the command and returns its exit code and error.
|
||||
func (cmd *Cmd) exec() (int, error) {
|
||||
err := cmd.Start()
|
||||
if err != nil {
|
||||
if cmd.process.ProcessState == nil {
|
||||
@@ -131,6 +177,19 @@ func (cmd *Cmd) Exec() (int, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// execErrorType maps an execution failure to a bounded semconv error.type
|
||||
// value.
|
||||
func execErrorType(ctx context.Context) string {
|
||||
switch {
|
||||
case errors.Is(ctx.Err(), context.DeadlineExceeded):
|
||||
return "context_deadline_exceeded"
|
||||
case errors.Is(ctx.Err(), context.Canceled):
|
||||
return "context_canceled"
|
||||
default:
|
||||
return "process_error"
|
||||
}
|
||||
}
|
||||
|
||||
// pipeOutput creates logs entries according to the process stdout and stderr.
|
||||
// It does nothing if the logging level is not debug.
|
||||
func (cmd *Cmd) pipeOutput() error {
|
||||
|
||||
136
pkg/gotenberg/cmd_test.go
Normal file
136
pkg/gotenberg/cmd_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
)
|
||||
|
||||
func newTestSpanRecorder(t *testing.T) *tracetest.SpanRecorder {
|
||||
t.Helper()
|
||||
recorder := tracetest.NewSpanRecorder()
|
||||
provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
|
||||
previous := otel.GetTracerProvider()
|
||||
otel.SetTracerProvider(provider)
|
||||
t.Cleanup(func() { otel.SetTracerProvider(previous) })
|
||||
return recorder
|
||||
}
|
||||
|
||||
func findSpan(recorder *tracetest.SpanRecorder, name string) sdktrace.ReadOnlySpan {
|
||||
for _, s := range recorder.Ended() {
|
||||
if s.Name() == name {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func spanAttr(span sdktrace.ReadOnlySpan, key string) (attribute.Value, bool) {
|
||||
for _, kv := range span.Attributes() {
|
||||
if string(kv.Key) == key {
|
||||
return kv.Value, true
|
||||
}
|
||||
}
|
||||
return attribute.Value{}, false
|
||||
}
|
||||
|
||||
func TestCmd_Exec_NilContext(t *testing.T) {
|
||||
cmd := Command(slog.New(slog.DiscardHandler), "true")
|
||||
|
||||
code, err := cmd.Exec()
|
||||
if err == nil {
|
||||
t.Error("expected an error for a nil context")
|
||||
}
|
||||
if code != 10 {
|
||||
t.Errorf("expected code 10, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmd_Exec_NoParentSpanProducesNoSpan(t *testing.T) {
|
||||
recorder := newTestSpanRecorder(t)
|
||||
|
||||
cmd, err := CommandContext(context.Background(), slog.New(slog.DiscardHandler), "sh", "-c", "exit 0")
|
||||
if err != nil {
|
||||
t.Fatalf("create command: %v", err)
|
||||
}
|
||||
|
||||
code, err := cmd.Exec()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if code != 0 {
|
||||
t.Errorf("expected code 0, got %d", code)
|
||||
}
|
||||
if n := len(recorder.Ended()); n != 0 {
|
||||
t.Errorf("expected no span without an active parent, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmd_Exec_RecordsSpanOnSuccess(t *testing.T) {
|
||||
recorder := newTestSpanRecorder(t)
|
||||
|
||||
ctx, parent := otel.Tracer("test").Start(context.Background(), "parent")
|
||||
cmd, err := CommandContext(ctx, slog.New(slog.DiscardHandler), "sh", "-c", "exit 0")
|
||||
if err != nil {
|
||||
t.Fatalf("create command: %v", err)
|
||||
}
|
||||
|
||||
code, err := cmd.Exec()
|
||||
parent.End()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if code != 0 {
|
||||
t.Errorf("expected code 0, got %d", code)
|
||||
}
|
||||
|
||||
span := findSpan(recorder, "process.exec")
|
||||
if span == nil {
|
||||
t.Fatal("expected a process.exec span to be recorded")
|
||||
}
|
||||
if span.Status().Code != codes.Ok {
|
||||
t.Errorf("expected status Ok, got %v", span.Status().Code)
|
||||
}
|
||||
if name, ok := spanAttr(span, "process.executable.name"); !ok || name.AsString() != "sh" {
|
||||
t.Errorf("expected process.executable.name=sh, got %q (present=%t)", name.AsString(), ok)
|
||||
}
|
||||
if exit, ok := spanAttr(span, "process.exit.code"); !ok || exit.AsInt64() != 0 {
|
||||
t.Errorf("expected process.exit.code=0, got %d (present=%t)", exit.AsInt64(), ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmd_Exec_RecordsSpanOnError(t *testing.T) {
|
||||
recorder := newTestSpanRecorder(t)
|
||||
|
||||
ctx, parent := otel.Tracer("test").Start(context.Background(), "parent")
|
||||
cmd, err := CommandContext(ctx, slog.New(slog.DiscardHandler), "sh", "-c", "exit 3")
|
||||
if err != nil {
|
||||
t.Fatalf("create command: %v", err)
|
||||
}
|
||||
|
||||
code, err := cmd.Exec()
|
||||
parent.End()
|
||||
if err == nil {
|
||||
t.Error("expected an error for a non-zero exit code")
|
||||
}
|
||||
if code != 3 {
|
||||
t.Errorf("expected exit code 3, got %d", code)
|
||||
}
|
||||
|
||||
span := findSpan(recorder, "process.exec")
|
||||
if span == nil {
|
||||
t.Fatal("expected a process.exec span to be recorded")
|
||||
}
|
||||
if span.Status().Code != codes.Error {
|
||||
t.Errorf("expected status Error, got %v", span.Status().Code)
|
||||
}
|
||||
if et, ok := spanAttr(span, "error.type"); !ok || et.AsString() != "process_error" {
|
||||
t.Errorf("expected error.type=process_error, got %q (present=%t)", et.AsString(), ok)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package gotenberg provides most of the logic of the module system.
|
||||
// Package gotenberg implements the core module system.
|
||||
//
|
||||
// caddyserver/caddy, licensed under the Apache License 2.0, has significantly
|
||||
// inspired this module system.
|
||||
// Significantly inspired by caddyserver/caddy, licensed under the Apache
|
||||
// License 2.0.
|
||||
//
|
||||
// More details are available on https://caddyserver.com/.
|
||||
// See https://caddyserver.com/.
|
||||
package gotenberg
|
||||
|
||||
52
pkg/gotenberg/errortype.go
Normal file
52
pkg/gotenberg/errortype.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// Engine-agnostic, low-cardinality error.type values shared by the conversion
|
||||
// engines. They are safe to use both as the semconv error.type span attribute
|
||||
// and as bounded metric label values.
|
||||
const (
|
||||
ErrorTypeTimeout = "timeout"
|
||||
ErrorTypeContextCancelled = "context_cancelled"
|
||||
ErrorTypeQueueSizeExceeded = "queue_size_exceeded"
|
||||
ErrorTypeProcessRestarting = "process_restarting"
|
||||
ErrorTypeInvalidInput = "invalid_input"
|
||||
ErrorTypeUnknown = "unknown"
|
||||
)
|
||||
|
||||
// ClassifyError maps err to a bounded, engine-agnostic error.type value. It
|
||||
// recognizes the failure modes shared by every engine: deadline, cancellation,
|
||||
// queue saturation, and process restart. It returns an empty string for a nil
|
||||
// error and [ErrorTypeUnknown] for anything it does not recognize, leaving
|
||||
// engine-specific refinement (such as [ErrorTypeInvalidInput]) to the caller.
|
||||
func ClassifyError(err error) string {
|
||||
switch {
|
||||
case err == nil:
|
||||
return ""
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return ErrorTypeTimeout
|
||||
case errors.Is(err, context.Canceled):
|
||||
return ErrorTypeContextCancelled
|
||||
case errors.Is(err, ErrMaximumQueueSizeExceeded):
|
||||
return ErrorTypeQueueSizeExceeded
|
||||
case errors.Is(err, ErrProcessAlreadyRestarting):
|
||||
return ErrorTypeProcessRestarting
|
||||
default:
|
||||
return ErrorTypeUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// SpanErrorType records errorType as the semconv error.type attribute on span.
|
||||
// It is a no-op when errorType is empty.
|
||||
func SpanErrorType(span trace.Span, errorType string) {
|
||||
if errorType == "" {
|
||||
return
|
||||
}
|
||||
span.SetAttributes(semconv.ErrorTypeKey.String(errorType))
|
||||
}
|
||||
60
pkg/gotenberg/errortype_test.go
Normal file
60
pkg/gotenberg/errortype_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
)
|
||||
|
||||
func TestClassifyError(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{"nil", nil, ""},
|
||||
{"deadline", context.DeadlineExceeded, ErrorTypeTimeout},
|
||||
{"canceled", context.Canceled, ErrorTypeContextCancelled},
|
||||
{"queue size exceeded", ErrMaximumQueueSizeExceeded, ErrorTypeQueueSizeExceeded},
|
||||
{"process restarting", ErrProcessAlreadyRestarting, ErrorTypeProcessRestarting},
|
||||
{"wrapped deadline", fmt.Errorf("convert: %w", context.DeadlineExceeded), ErrorTypeTimeout},
|
||||
{"joined queue", errors.Join(errors.New("attempt"), ErrMaximumQueueSizeExceeded), ErrorTypeQueueSizeExceeded},
|
||||
{"arbitrary", errors.New("boom"), ErrorTypeUnknown},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := ClassifyError(tc.err); got != tc.want {
|
||||
t.Errorf("ClassifyError(%v) = %q, want %q", tc.err, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpanErrorType(t *testing.T) {
|
||||
recorder := newTestSpanRecorder(t)
|
||||
|
||||
_, span := otel.Tracer("test").Start(context.Background(), "engine.Op")
|
||||
SpanErrorType(span, "") // no-op, must not add an attribute
|
||||
SpanErrorType(span, ErrorTypeTimeout) // sets error.type
|
||||
span.End()
|
||||
|
||||
got := findSpan(recorder, "engine.Op")
|
||||
if got == nil {
|
||||
t.Fatal("expected the span to be recorded")
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, kv := range got.Attributes() {
|
||||
if string(kv.Key) == "error.type" {
|
||||
count++
|
||||
if kv.Value.AsString() != ErrorTypeTimeout {
|
||||
t.Errorf("expected error.type=%q, got %q", ErrorTypeTimeout, kv.Value.AsString())
|
||||
}
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected exactly one error.type attribute, got %d", count)
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,12 @@ func (h traceContextHandler) WithGroup(name string) slog.Handler {
|
||||
}
|
||||
|
||||
// NewStdHandler returns a [slog.Handler] instance for the standard output.
|
||||
func NewStdHandler(level slog.Level, format string, fieldsPrefix string, enableGcpFields bool) (slog.Handler, error) {
|
||||
// upperLevelCase is the value of the level-case setting that keeps the level
|
||||
// field uppercase in the standard output. It mirrors gotenberg.UpperLevelCase,
|
||||
// duplicated here because the internal log package cannot import gotenberg.
|
||||
const upperLevelCase = "upper"
|
||||
|
||||
func NewStdHandler(level slog.Level, format string, fieldsPrefix string, enableGcpFields bool, levelCase string) (slog.Handler, error) {
|
||||
// #nosec: G115
|
||||
isTerminal := term.IsTerminal(int(os.Stdout.Fd()))
|
||||
|
||||
@@ -82,7 +87,11 @@ func NewStdHandler(level slog.Level, format string, fieldsPrefix string, enableG
|
||||
a.Key = "severity"
|
||||
a.Value = slog.StringValue(gcpSeverity(l))
|
||||
default:
|
||||
a.Value = slog.StringValue(strings.ToLower(l.String()))
|
||||
if levelCase == upperLevelCase {
|
||||
a.Value = slog.StringValue(l.String())
|
||||
} else {
|
||||
a.Value = slog.StringValue(strings.ToLower(l.String()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
53
pkg/gotenberg/internal/log/stdhandler_test.go
Normal file
53
pkg/gotenberg/internal/log/stdhandler_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewStdHandler_LevelCase(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
levelCase string
|
||||
want string
|
||||
}{
|
||||
{"lower is the default behavior", "lower", "info"},
|
||||
{"upper keeps slog casing", "upper", "INFO"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
original := os.Stderr
|
||||
reader, writer, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("create pipe: %v", err)
|
||||
}
|
||||
os.Stderr = writer
|
||||
defer func() { os.Stderr = original }()
|
||||
|
||||
handler, err := NewStdHandler(slog.LevelInfo, "json", "", false, tc.levelCase)
|
||||
if err != nil {
|
||||
t.Fatalf("create handler: %v", err)
|
||||
}
|
||||
|
||||
slog.New(handler).Info("hello")
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close writer: %v", err)
|
||||
}
|
||||
out, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("read output: %v", err)
|
||||
}
|
||||
|
||||
var record map[string]any
|
||||
if err := json.Unmarshal(out, &record); err != nil {
|
||||
t.Fatalf("parse log line %q: %v", out, err)
|
||||
}
|
||||
if record["level"] != tc.want {
|
||||
t.Errorf("level = %v, want %v", record["level"], tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,24 @@
|
||||
// Package otel gathers initialization utilities for OpenTelemetry
|
||||
// instrumentation.
|
||||
//
|
||||
// This package has been significantly inspired by
|
||||
// https://github.com/lucavallin/gotel.
|
||||
// Significantly inspired by https://github.com/lucavallin/gotel.
|
||||
//
|
||||
// See: https://opentelemetry.io/.
|
||||
// # Sampling
|
||||
//
|
||||
// No sampler is configured in code, so the SDK default applies:
|
||||
// parentbased_always_on. Every trace is recorded and exported. Operators tune
|
||||
// sampling through the standard environment variables, honored by the SDK:
|
||||
//
|
||||
// OTEL_TRACES_SAMPLER e.g. parentbased_traceidratio, always_off
|
||||
// OTEL_TRACES_SAMPLER_ARG e.g. 0.1 for a 10% ratio
|
||||
//
|
||||
// Head sampling drops whole traces up front, including the rare slow or failed
|
||||
// conversions that matter most for diagnosis. For high-throughput deployments,
|
||||
// prefer keeping head sampling permissive and applying tail sampling in the
|
||||
// collector (sample on error or high latency), which decides after a trace
|
||||
// completes. Gotenberg emits trace-based metric exemplars, so the conversion
|
||||
// histograms still link to representative traces regardless of the head
|
||||
// sampling ratio.
|
||||
//
|
||||
// See https://opentelemetry.io/.
|
||||
package otel
|
||||
|
||||
@@ -14,33 +14,63 @@ import (
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/sdk/log"
|
||||
"go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/metric/exemplar"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
"go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
|
||||
)
|
||||
|
||||
// buildResource assembles the OpenTelemetry resource shared by the tracer,
|
||||
// meter, and logger providers. Detection is best-effort: a detector or merge
|
||||
// failure is logged and the build proceeds with whatever was gathered, so a
|
||||
// flaky environment never prevents telemetry from starting.
|
||||
func buildResource(ctx context.Context, logger *slog.Logger, serviceName, serviceVersion string) *resource.Resource {
|
||||
base := resource.NewWithAttributes(
|
||||
semconv.SchemaURL,
|
||||
semconv.ServiceName(serviceName),
|
||||
semconv.ServiceVersion(serviceVersion),
|
||||
)
|
||||
|
||||
// Granular detectors only. The WithProcess() bundle is deliberately omitted
|
||||
// because it adds process.command_args/process.command_line, which can carry
|
||||
// proxy credentials and host-resolver rules passed on the command line.
|
||||
detected, err := resource.New(ctx,
|
||||
resource.WithFromEnv(),
|
||||
resource.WithTelemetrySDK(),
|
||||
resource.WithHost(),
|
||||
resource.WithHostID(),
|
||||
resource.WithOS(),
|
||||
resource.WithContainer(),
|
||||
resource.WithProcessPID(),
|
||||
resource.WithProcessExecutableName(),
|
||||
resource.WithProcessExecutablePath(),
|
||||
resource.WithProcessRuntimeName(),
|
||||
resource.WithProcessRuntimeVersion(),
|
||||
resource.WithProcessRuntimeDescription(),
|
||||
)
|
||||
if err != nil {
|
||||
logger.WarnContext(ctx, fmt.Sprintf("partially detect OpenTelemetry resource: %s", err))
|
||||
}
|
||||
if detected == nil {
|
||||
return base
|
||||
}
|
||||
|
||||
merged, err := resource.Merge(detected, base)
|
||||
if err != nil {
|
||||
logger.WarnContext(ctx, fmt.Sprintf("merge OpenTelemetry resource: %s", err))
|
||||
return base
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// InitTracerProvider initializes the OpenTelemetry tracer provider.
|
||||
func InitTracerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, err error) {
|
||||
initOtelLogger(logger)
|
||||
|
||||
ctx := context.Background()
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get hostname: %w", err)
|
||||
}
|
||||
|
||||
res, err := resource.Merge(
|
||||
resource.Default(),
|
||||
resource.NewWithAttributes(
|
||||
semconv.SchemaURL,
|
||||
semconv.ServiceName(serviceName),
|
||||
semconv.ServiceVersion(serviceVersion),
|
||||
semconv.HostName(hostname),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("merge resource: %w", err)
|
||||
}
|
||||
res := buildResource(ctx, logger, serviceName, serviceVersion)
|
||||
|
||||
traceOpts := []trace.TracerProviderOption{
|
||||
trace.WithResource(res),
|
||||
@@ -71,27 +101,13 @@ func InitMeterProvider(logger *slog.Logger, serviceName, serviceVersion string)
|
||||
initOtelLogger(logger)
|
||||
|
||||
ctx := context.Background()
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get hostname: %w", err)
|
||||
}
|
||||
|
||||
res, err := resource.Merge(
|
||||
resource.Default(),
|
||||
resource.NewWithAttributes(
|
||||
semconv.SchemaURL,
|
||||
semconv.ServiceName(serviceName),
|
||||
semconv.ServiceVersion(serviceVersion),
|
||||
semconv.HostName(hostname),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("merge resource: %w", err)
|
||||
}
|
||||
res := buildResource(ctx, logger, serviceName, serviceVersion)
|
||||
|
||||
metricOpts := []metric.Option{
|
||||
metric.WithResource(res),
|
||||
}
|
||||
metricOpts = append(metricOpts, exemplarFilterOptions()...)
|
||||
|
||||
metricReader, err := autoexport.NewMetricReader(ctx)
|
||||
if err != nil {
|
||||
@@ -108,28 +124,24 @@ func InitMeterProvider(logger *slog.Logger, serviceName, serviceVersion string)
|
||||
return meterProvider.Shutdown, nil
|
||||
}
|
||||
|
||||
// exemplarFilterOptions returns the meter provider options that pin trace-based
|
||||
// exemplars, so the histograms expose the trace id of a representative
|
||||
// measurement. It yields no option when the operator selects a filter via
|
||||
// OTEL_METRICS_EXEMPLAR_FILTER, letting the SDK's own env handling win.
|
||||
func exemplarFilterOptions() []metric.Option {
|
||||
if _, ok := os.LookupEnv("OTEL_METRICS_EXEMPLAR_FILTER"); ok {
|
||||
return nil
|
||||
}
|
||||
return []metric.Option{metric.WithExemplarFilter(exemplar.TraceBasedFilter)}
|
||||
}
|
||||
|
||||
// InitLoggerProvider initializes the OpenTelemetry logger provider.
|
||||
func InitLoggerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, handler slog.Handler, err error) {
|
||||
initOtelLogger(logger)
|
||||
|
||||
ctx := context.Background()
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("get hostname: %w", err)
|
||||
}
|
||||
|
||||
res, err := resource.Merge(
|
||||
resource.Default(),
|
||||
resource.NewWithAttributes(
|
||||
semconv.SchemaURL,
|
||||
semconv.ServiceName(serviceName),
|
||||
semconv.ServiceVersion(serviceVersion),
|
||||
semconv.HostName(hostname),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("merge resource: %w", err)
|
||||
}
|
||||
res := buildResource(ctx, logger, serviceName, serviceVersion)
|
||||
|
||||
logOpts := []log.LoggerProviderOption{
|
||||
log.WithResource(res),
|
||||
|
||||
151
pkg/gotenberg/internal/otel/otel_test.go
Normal file
151
pkg/gotenberg/internal/otel/otel_test.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package otel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/metric/exemplar"
|
||||
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
|
||||
)
|
||||
|
||||
func TestBuildResource(t *testing.T) {
|
||||
res := buildResource(context.Background(), slog.New(slog.DiscardHandler), "gotenberg", "v8.0.0")
|
||||
|
||||
got := map[string]struct{}{}
|
||||
values := map[string]string{}
|
||||
for _, kv := range res.Attributes() {
|
||||
got[string(kv.Key)] = struct{}{}
|
||||
values[string(kv.Key)] = kv.Value.AsString()
|
||||
}
|
||||
|
||||
if values[string(semconv.ServiceNameKey)] != "gotenberg" {
|
||||
t.Errorf("service.name = %q, want %q", values[string(semconv.ServiceNameKey)], "gotenberg")
|
||||
}
|
||||
if values[string(semconv.ServiceVersionKey)] != "v8.0.0" {
|
||||
t.Errorf("service.version = %q, want %q", values[string(semconv.ServiceVersionKey)], "v8.0.0")
|
||||
}
|
||||
|
||||
for _, key := range []string{
|
||||
string(semconv.HostNameKey),
|
||||
string(semconv.OSTypeKey),
|
||||
string(semconv.ProcessRuntimeNameKey),
|
||||
} {
|
||||
if _, ok := got[key]; !ok {
|
||||
t.Errorf("expected resource attribute %q to be present", key)
|
||||
}
|
||||
}
|
||||
|
||||
// The command-line bundle must never be detected (it can leak credentials).
|
||||
for _, key := range []string{"process.command_args", "process.command_line"} {
|
||||
if _, ok := got[key]; ok {
|
||||
t.Errorf("did not expect sensitive resource attribute %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestInitTracerProvider_HonorsSamplerEnv guards the contract that the tracer
|
||||
// provider keeps honoring OTEL_TRACES_SAMPLER. The SDK reads it only when no
|
||||
// explicit sampler is configured, so any future WithSampler() would silently
|
||||
// break operator-side sampling control.
|
||||
func TestInitTracerProvider_HonorsSamplerEnv(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
sampler string
|
||||
wantSampled bool
|
||||
}{
|
||||
{"always off", "always_off", false},
|
||||
{"always on", "always_on", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("OTEL_TRACES_EXPORTER", "none")
|
||||
t.Setenv("OTEL_TRACES_SAMPLER", tc.sampler)
|
||||
|
||||
shutdown, err := InitTracerProvider(slog.New(slog.DiscardHandler), "test", "v0.0.0")
|
||||
if err != nil {
|
||||
t.Fatalf("init tracer provider: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = shutdown(context.Background()) })
|
||||
|
||||
_, span := otel.Tracer("test").Start(context.Background(), "span")
|
||||
span.End()
|
||||
|
||||
if got := span.SpanContext().IsSampled(); got != tc.wantSampled {
|
||||
t.Errorf("OTEL_TRACES_SAMPLER=%q: IsSampled() = %v, want %v", tc.sampler, got, tc.wantSampled)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExemplarFilterOptions(t *testing.T) {
|
||||
t.Run("default pins trace-based", func(t *testing.T) {
|
||||
if v, ok := os.LookupEnv("OTEL_METRICS_EXEMPLAR_FILTER"); ok {
|
||||
os.Unsetenv("OTEL_METRICS_EXEMPLAR_FILTER")
|
||||
t.Cleanup(func() { os.Setenv("OTEL_METRICS_EXEMPLAR_FILTER", v) })
|
||||
}
|
||||
if got := exemplarFilterOptions(); len(got) != 1 {
|
||||
t.Errorf("expected 1 option when env unset, got %d", len(got))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("env override yields no option", func(t *testing.T) {
|
||||
t.Setenv("OTEL_METRICS_EXEMPLAR_FILTER", "always_off")
|
||||
if got := exemplarFilterOptions(); len(got) != 0 {
|
||||
t.Errorf("expected 0 options when env set, got %d", len(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestMeterProvider_TraceBasedExemplar guards that the trace-based filter we pin
|
||||
// actually attaches a trace id to a histogram measurement recorded inside a
|
||||
// sampled span.
|
||||
func TestMeterProvider_TraceBasedExemplar(t *testing.T) {
|
||||
reader := sdkmetric.NewManualReader()
|
||||
provider := sdkmetric.NewMeterProvider(
|
||||
sdkmetric.WithReader(reader),
|
||||
sdkmetric.WithExemplarFilter(exemplar.TraceBasedFilter),
|
||||
)
|
||||
t.Cleanup(func() { _ = provider.Shutdown(context.Background()) })
|
||||
|
||||
hist, err := provider.Meter("test").Float64Histogram("conversion.duration")
|
||||
if err != nil {
|
||||
t.Fatalf("create histogram: %v", err)
|
||||
}
|
||||
|
||||
tracer := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.AlwaysSample())).Tracer("test")
|
||||
ctx, span := tracer.Start(context.Background(), "conversion")
|
||||
hist.Record(ctx, 1.0)
|
||||
traceID := span.SpanContext().TraceID()
|
||||
span.End()
|
||||
|
||||
var rm metricdata.ResourceMetrics
|
||||
if err := reader.Collect(context.Background(), &rm); err != nil {
|
||||
t.Fatalf("collect: %v", err)
|
||||
}
|
||||
|
||||
var found bool
|
||||
for _, sm := range rm.ScopeMetrics {
|
||||
for _, m := range sm.Metrics {
|
||||
hd, ok := m.Data.(metricdata.Histogram[float64])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, dp := range hd.DataPoints {
|
||||
for _, ex := range dp.Exemplars {
|
||||
if string(ex.TraceID) == string(traceID[:]) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Error("expected a trace-based exemplar carrying the span trace id")
|
||||
}
|
||||
}
|
||||
@@ -45,20 +45,23 @@ 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
|
||||
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)
|
||||
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 string, opts EncryptOptions) 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
|
||||
InjectFacturXXMPMock func(ctx context.Context, logger *slog.Logger, facturX FacturX, inputPath string) error
|
||||
ReadPdfAConformanceMock func(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
||||
@@ -93,14 +96,18 @@ func (engine *PdfEngineMock) ReadBookmarks(ctx context.Context, logger *slog.Log
|
||||
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) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts EncryptOptions) error {
|
||||
return engine.EncryptMock(ctx, logger, inputPath, opts)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -117,6 +124,14 @@ func (engine *PdfEngineMock) Rotate(ctx context.Context, logger *slog.Logger, in
|
||||
return engine.RotateMock(ctx, logger, inputPath, angle, pages)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX FacturX, inputPath string) error {
|
||||
return engine.InjectFacturXXMPMock(ctx, logger, facturX, inputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
|
||||
return engine.ReadPdfAConformanceMock(ctx, logger, inputPath)
|
||||
}
|
||||
|
||||
// PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface.
|
||||
type PdfEngineProviderMock struct {
|
||||
PdfEngineMock func() (PdfEngine, error)
|
||||
@@ -147,13 +162,14 @@ func (p *ProcessMock) Healthy(logger *slog.Logger) bool {
|
||||
|
||||
// ProcessSupervisorMock is a mock for the [ProcessSupervisor] interface.
|
||||
type ProcessSupervisorMock struct {
|
||||
LaunchMock func() error
|
||||
ShutdownMock func() error
|
||||
HealthyMock func() bool
|
||||
RunMock func(ctx context.Context, logger *slog.Logger, task func() error) error
|
||||
ReqQueueSizeMock func() int64
|
||||
RestartsCountMock func() int64
|
||||
ActiveTasksCountMock func() int64
|
||||
LaunchMock func() error
|
||||
ShutdownMock func() error
|
||||
HealthyMock func() bool
|
||||
RunMock func(ctx context.Context, logger *slog.Logger, task func() error) error
|
||||
ReqQueueSizeMock func() int64
|
||||
RestartsCountMock func() int64
|
||||
ActiveTasksCountMock func() int64
|
||||
ConversionsSinceRestartMock func() int64
|
||||
}
|
||||
|
||||
func (s *ProcessSupervisorMock) Launch() error {
|
||||
@@ -184,6 +200,10 @@ func (s *ProcessSupervisorMock) ActiveTasksCount() int64 {
|
||||
return s.ActiveTasksCountMock()
|
||||
}
|
||||
|
||||
func (s *ProcessSupervisorMock) ConversionsSinceRestart() int64 {
|
||||
return s.ConversionsSinceRestartMock()
|
||||
}
|
||||
|
||||
// MetricsProviderMock is a mock for the [MetricsProvider] interface.
|
||||
type MetricsProviderMock struct {
|
||||
MetricsMock func() ([]Metric, error)
|
||||
|
||||
582
pkg/gotenberg/outbound.go
Normal file
582
pkg/gotenberg/outbound.go
Normal file
@@ -0,0 +1,582 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"golang.org/x/net/http/httpproxy"
|
||||
)
|
||||
|
||||
// ErrNonPublicIP indicates that an outbound URL targets an IP address that
|
||||
// is not reachable on the public internet. This covers loopback, RFC1918
|
||||
// private, link-local, unspecified, multicast, and IPv6 unique-local
|
||||
// (fc00::/7) addresses, as well as their IPv4-mapped IPv6 wrappers (for
|
||||
// example [::ffff:127.0.0.1]).
|
||||
var ErrNonPublicIP = errors.New("non-public IP")
|
||||
|
||||
// ErrPublicIP indicates that an outbound URL targets an IP address that is
|
||||
// reachable on the public internet. It is returned when a caller opts
|
||||
// into denying public destinations via [WithDenyPublicIPs]; typical use
|
||||
// cases are air-gapped or data-governed deployments where Gotenberg must
|
||||
// only talk to hosts on a private network.
|
||||
var ErrPublicIP = errors.New("public IP")
|
||||
|
||||
// netipResolver is the subset of [net.Resolver] used by [resolveHost].
|
||||
// Defining it as an interface allows tests to substitute a stub resolver.
|
||||
type netipResolver interface {
|
||||
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
|
||||
}
|
||||
|
||||
// outboundResolver is the resolver used by [resolveHost]. It is a
|
||||
// package-level variable so that tests can substitute a stub resolver.
|
||||
var outboundResolver netipResolver = net.DefaultResolver
|
||||
|
||||
// outboundDialer is the underlying dialer used by [secureDialContext]. It is
|
||||
// a package-level variable so that tests can replace it.
|
||||
var outboundDialer = &net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}
|
||||
|
||||
// nonPublicIPv6Prefixes lists IPv6 ranges that the standard library does
|
||||
// not classify via [netip.Addr] helpers but that must not be considered
|
||||
// public:
|
||||
//
|
||||
// - 2002::/16 6to4 (RFC 3056, deprecated by RFC 7526). Bits 16-47
|
||||
// embed an IPv4 destination, including private ones.
|
||||
// - 2001::/32 Teredo (RFC 4380). Bits 96-127 embed an IPv4
|
||||
// destination, including private ones.
|
||||
// - 64:ff9b::/96 NAT64 well-known prefix (RFC 6052). Low 32 bits
|
||||
// embed an IPv4 destination translated by a NAT64 gateway.
|
||||
// - 64:ff9b:1::/48 NAT64 local-use prefix (RFC 8215). Same risk.
|
||||
// - fec0::/10 Deprecated site-local (RFC 3879). Not covered by
|
||||
// [netip.Addr.IsPrivate] which only handles fc00::/7.
|
||||
// - ::/96 IPv4-compatible IPv6 (deprecated). Embeds an IPv4
|
||||
// destination and is not handled by [netip.Addr.Unmap].
|
||||
// - 2001:db8::/32 Documentation range (RFC 3849). Never routable.
|
||||
// - 100::/64 Discard prefix (RFC 6666).
|
||||
var nonPublicIPv6Prefixes = []netip.Prefix{
|
||||
netip.MustParsePrefix("2002::/16"),
|
||||
netip.MustParsePrefix("2001::/32"),
|
||||
netip.MustParsePrefix("64:ff9b::/96"),
|
||||
netip.MustParsePrefix("64:ff9b:1::/48"),
|
||||
netip.MustParsePrefix("fec0::/10"),
|
||||
netip.MustParsePrefix("::/96"),
|
||||
netip.MustParsePrefix("2001:db8::/32"),
|
||||
netip.MustParsePrefix("100::/64"),
|
||||
}
|
||||
|
||||
// IsPublicIP reports whether addr is reachable on the public internet. It
|
||||
// returns false for loopback, private (RFC1918), link-local, unspecified,
|
||||
// multicast, and unique-local addresses. IPv4-mapped IPv6 addresses are
|
||||
// unmapped before evaluation so that [::ffff:127.0.0.1] is correctly
|
||||
// identified as loopback.
|
||||
//
|
||||
// IPv6 prefixes that tunnel or translate to an embedded IPv4 destination
|
||||
// (6to4, Teredo, NAT64) are rejected wholesale rather than recursed into,
|
||||
// because a host that routes them implicitly trusts the IPv4 mapping and
|
||||
// the prefixes themselves are deprecated or translation-only. See
|
||||
// [nonPublicIPv6Prefixes] for the full list and rationale.
|
||||
func IsPublicIP(addr netip.Addr) bool {
|
||||
if !addr.IsValid() {
|
||||
return false
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
switch {
|
||||
case addr.IsLoopback(),
|
||||
addr.IsPrivate(),
|
||||
addr.IsLinkLocalUnicast(),
|
||||
addr.IsLinkLocalMulticast(),
|
||||
addr.IsMulticast(),
|
||||
addr.IsUnspecified(),
|
||||
addr.IsInterfaceLocalMulticast():
|
||||
return false
|
||||
}
|
||||
if addr.Is6() {
|
||||
for _, p := range nonPublicIPv6Prefixes {
|
||||
if p.Contains(addr) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ResolveAndCheckPublic resolves host and rejects any resolved address
|
||||
// that fails [IsPublicIP] with [ErrNonPublicIP]. It is the strict
|
||||
// equivalent of [DecideOutbound] with [WithDenyPrivateIPs] true for a
|
||||
// bare host. Callers that need a different policy should use
|
||||
// [DecideOutbound] directly.
|
||||
func ResolveAndCheckPublic(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
return resolveHost(ctx, host, true, false)
|
||||
}
|
||||
|
||||
// resolveHost resolves host and returns the addresses. When denyPrivate
|
||||
// is true, a non-public address is rejected with [ErrNonPublicIP]. When
|
||||
// denyPublic is true, a public address is rejected with [ErrPublicIP].
|
||||
// Both checks may be active at the same time, in which case any
|
||||
// resolved address fails and the caller must rely on an allow-list
|
||||
// bypass.
|
||||
func resolveHost(ctx context.Context, host string, denyPrivate, denyPublic bool) ([]netip.Addr, error) {
|
||||
if host == "" {
|
||||
return nil, errors.New("empty host")
|
||||
}
|
||||
|
||||
check := func(a netip.Addr) error {
|
||||
public := IsPublicIP(a)
|
||||
if denyPublic && public {
|
||||
return fmt.Errorf("%q: %w", a, ErrPublicIP)
|
||||
}
|
||||
if denyPrivate && !public {
|
||||
return fmt.Errorf("%q: %w", a, ErrNonPublicIP)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if addr, err := netip.ParseAddr(host); err == nil {
|
||||
if err := check(addr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []netip.Addr{addr}, nil
|
||||
}
|
||||
addrs, err := outboundResolver.LookupNetIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve %q: %w", host, err)
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return nil, fmt.Errorf("resolve %q: no addresses returned", host)
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if err := check(a); err != nil {
|
||||
return nil, fmt.Errorf("%q resolves to rejected address %w", host, err)
|
||||
}
|
||||
}
|
||||
return addrs, nil
|
||||
}
|
||||
|
||||
// OutboundDecision is the result of validating an outbound URL via
|
||||
// [DecideOutbound]. Callers use it to dial the destination either directly
|
||||
// (operator-approved allow-list match, Bypass true) or via [DialPinned] so
|
||||
// that the connect targets the IPs resolved at validation time. Passing
|
||||
// the decision to the dialer closes the window between validation and
|
||||
// connect that DNS rebinding exploits.
|
||||
type OutboundDecision struct {
|
||||
// Bypass is true when an allow-list pattern matched the URL. The
|
||||
// operator has explicitly opted into the destination; the caller
|
||||
// should dial directly without an additional IP check.
|
||||
Bypass bool
|
||||
|
||||
// Pinned holds the IPs resolved for the URL host. The caller should
|
||||
// dial one of these via [DialPinned] to prevent DNS rebinding between
|
||||
// validation and connect.
|
||||
Pinned []netip.Addr
|
||||
}
|
||||
|
||||
// outboundDecisionKey is the context key under which an [OutboundDecision]
|
||||
// is stored.
|
||||
type outboundDecisionKey struct{}
|
||||
|
||||
// decideConfig carries optional settings for [DecideOutbound] and
|
||||
// [FilterOutboundURL]. See [DecideOption] for how callers configure it.
|
||||
type decideConfig struct {
|
||||
denyPrivateIPs bool
|
||||
denyPublicIPs bool
|
||||
}
|
||||
|
||||
// DecideOption customizes how [DecideOutbound] and [FilterOutboundURL]
|
||||
// validate a URL. Options are applied in order on top of the permissive
|
||||
// defaults (no IP-class rejection).
|
||||
type DecideOption func(*decideConfig)
|
||||
|
||||
// WithDenyPrivateIPs rejects URLs whose host resolves to a non-public IP
|
||||
// address (loopback, RFC1918, link-local, unique-local, multicast,
|
||||
// unspecified). DNS still runs and the returned [OutboundDecision] still
|
||||
// carries the resolved IPs for dial pinning, so enabling or disabling
|
||||
// this option does not affect DNS-rebinding protection. Use it on
|
||||
// internet-exposed deployments to mitigate SSRF against internal
|
||||
// services.
|
||||
func WithDenyPrivateIPs(deny bool) DecideOption {
|
||||
return func(c *decideConfig) { c.denyPrivateIPs = deny }
|
||||
}
|
||||
|
||||
// WithDenyPublicIPs rejects URLs whose host resolves to a public IP
|
||||
// address. Use it on air-gapped or data-governed deployments where
|
||||
// Gotenberg must only reach hosts on a private network; the option
|
||||
// prevents data exfiltration to attacker-controlled public servers via
|
||||
// webhook callbacks, downloadFrom URLs, or user-supplied stamp sources.
|
||||
// May be combined with [WithDenyPrivateIPs]; in that case every resolved
|
||||
// address fails and only an allow-list bypass permits a destination.
|
||||
func WithDenyPublicIPs(deny bool) DecideOption {
|
||||
return func(c *decideConfig) { c.denyPublicIPs = deny }
|
||||
}
|
||||
|
||||
// httpLikeScheme reports whether scheme is one of http, https, ws, or wss.
|
||||
// Only these schemes go through the IP-based address check; data, blob,
|
||||
// file, and other schemes are filtered by the regex layer alone.
|
||||
func httpLikeScheme(scheme string) bool {
|
||||
switch scheme {
|
||||
case "http", "https", "ws", "wss":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DecideOutbound parses rawURL, runs the regex allow/deny lists against
|
||||
// the normalized form, and (when no allow-list match) resolves the host
|
||||
// and applies the IP-class checks selected by opts. It returns the
|
||||
// resulting [OutboundDecision] so the caller can pin the dial to the IPs
|
||||
// that were resolved here and skip a second DNS lookup later, which
|
||||
// closes the DNS rebinding window that affects callers that only receive
|
||||
// an error from [FilterOutboundURL].
|
||||
//
|
||||
// The semantics:
|
||||
//
|
||||
// 1. The URL is parsed and its scheme and host lowercased.
|
||||
// 2. allowList and denyList apply against the normalized form with OR
|
||||
// semantics. The deny-list always applies.
|
||||
// 3. For http, https, ws, and wss, the host is resolved and every
|
||||
// resolved address must satisfy the enabled IP-class checks
|
||||
// ([WithDenyPrivateIPs], [WithDenyPublicIPs]). An allow-list match
|
||||
// bypasses the IP-class checks and the returned decision carries
|
||||
// Bypass true. Otherwise the decision carries Pinned with the
|
||||
// resolved addresses.
|
||||
//
|
||||
// Callers that dial the destination themselves must honor Bypass and
|
||||
// Pinned: bypassed URLs dial the hostname directly (operator opt-in);
|
||||
// pinned URLs must dial one of Pinned via [DialPinned].
|
||||
func DecideOutbound(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time, opts ...DecideOption) (OutboundDecision, error) {
|
||||
cfg := decideConfig{}
|
||||
for _, opt := range opts {
|
||||
opt(&cfg)
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return OutboundDecision{}, fmt.Errorf("parse URL %q: %w", rawURL, ErrFiltered)
|
||||
}
|
||||
parsed.Scheme = strings.ToLower(parsed.Scheme)
|
||||
parsed.Host = strings.ToLower(parsed.Host)
|
||||
normalized := parsed.String()
|
||||
|
||||
allowMatched := false
|
||||
if len(allowList) > 0 {
|
||||
for _, pattern := range allowList {
|
||||
clone := regexp2.MustCompile(pattern.String(), 0)
|
||||
clone.MatchTimeout = time.Until(deadline)
|
||||
|
||||
ok, err := clone.MatchString(normalized)
|
||||
if err != nil {
|
||||
if time.Now().After(deadline) {
|
||||
return OutboundDecision{}, context.DeadlineExceeded
|
||||
}
|
||||
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
|
||||
}
|
||||
|
||||
if ok {
|
||||
allowMatched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !allowMatched {
|
||||
return OutboundDecision{}, fmt.Errorf("'%s' does not match any expression from the allowed list: %w", normalized, ErrFiltered)
|
||||
}
|
||||
}
|
||||
|
||||
for _, pattern := range denyList {
|
||||
clone := regexp2.MustCompile(pattern.String(), 0)
|
||||
clone.MatchTimeout = time.Until(deadline)
|
||||
|
||||
ok, err := clone.MatchString(normalized)
|
||||
if err != nil {
|
||||
if time.Now().After(deadline) {
|
||||
return OutboundDecision{}, context.DeadlineExceeded
|
||||
}
|
||||
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
|
||||
}
|
||||
|
||||
if ok {
|
||||
return OutboundDecision{}, fmt.Errorf("'%s' matches the expression from the denied list: %w", normalized, ErrFiltered)
|
||||
}
|
||||
}
|
||||
|
||||
if allowMatched {
|
||||
return OutboundDecision{Bypass: true}, nil
|
||||
}
|
||||
|
||||
if !httpLikeScheme(parsed.Scheme) {
|
||||
return OutboundDecision{}, nil
|
||||
}
|
||||
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
return OutboundDecision{}, fmt.Errorf("URL %q has no host: %w", rawURL, ErrFiltered)
|
||||
}
|
||||
|
||||
addrs, err := resolveHost(ctx, host, cfg.denyPrivateIPs, cfg.denyPublicIPs)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ErrNonPublicIP):
|
||||
return OutboundDecision{}, fmt.Errorf("'%s' targets a non-public address: %w", normalized, ErrFiltered)
|
||||
case errors.Is(err, ErrPublicIP):
|
||||
return OutboundDecision{}, fmt.Errorf("'%s' targets a public address: %w", normalized, ErrFiltered)
|
||||
default:
|
||||
return OutboundDecision{}, fmt.Errorf("validate '%s' host: %w", normalized, err)
|
||||
}
|
||||
}
|
||||
|
||||
return OutboundDecision{Pinned: addrs}, nil
|
||||
}
|
||||
|
||||
// FilterOutboundURL validates that rawURL is acceptable for an outbound
|
||||
// request from Gotenberg. It is the URL-aware replacement for
|
||||
// [FilterDeadline] and should be preferred for any new code that filters
|
||||
// a URL before issuing or instructing an outbound request.
|
||||
//
|
||||
// The default behavior is permissive: the URL passes as long as it clears
|
||||
// the regex allow-list and deny-list. Callers that need IP-class checks
|
||||
// opt in via [WithDenyPrivateIPs] or [WithDenyPublicIPs]. The deny-list
|
||||
// always applies and cannot be bypassed by an allow-list match.
|
||||
func FilterOutboundURL(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time, opts ...DecideOption) error {
|
||||
_, err := DecideOutbound(ctx, rawURL, allowList, denyList, deadline, opts...)
|
||||
return err
|
||||
}
|
||||
|
||||
// outboundRoundTripper is an [http.RoundTripper] that validates each
|
||||
// request URL via [DecideOutbound] and stashes the resulting
|
||||
// [OutboundDecision] in the request context so that [secureDialContext]
|
||||
// can pin the dial or bypass the IP check as appropriate. Because the
|
||||
// http.Client invokes RoundTrip again for each redirect hop, this also
|
||||
// re-validates redirect targets without a separate CheckRedirect.
|
||||
type outboundRoundTripper struct {
|
||||
base http.RoundTripper
|
||||
allowList []*regexp2.Regexp
|
||||
denyList []*regexp2.Regexp
|
||||
opts []DecideOption
|
||||
}
|
||||
|
||||
// RoundTrip validates req.URL and delegates to the base transport.
|
||||
func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
deadline, ok := req.Context().Deadline()
|
||||
if !ok {
|
||||
deadline = time.Now().Add(30 * time.Second)
|
||||
}
|
||||
|
||||
decision, err := DecideOutbound(req.Context(), req.URL.String(), rt.allowList, rt.denyList, deadline, rt.opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx := context.WithValue(req.Context(), outboundDecisionKey{}, decision)
|
||||
return rt.base.RoundTrip(req.WithContext(ctx))
|
||||
}
|
||||
|
||||
// NewOutboundHttpClient returns an [http.Client] that validates every
|
||||
// outbound request URL via the same logic as [FilterOutboundURL] and
|
||||
// pins the resulting dial to the resolved IPs.
|
||||
//
|
||||
// The client re-validates redirect targets automatically because the
|
||||
// underlying [http.Client] invokes the wrapping [http.RoundTripper] once
|
||||
// per hop. This closes the redirect-based SSRF bypass that affects raw
|
||||
// [http.Client] usage when no CheckRedirect is set.
|
||||
//
|
||||
// The default posture is permissive; callers pass [WithDenyPrivateIPs]
|
||||
// or [WithDenyPublicIPs] to opt into IP-class rejection.
|
||||
//
|
||||
// When enableEnvironmentProxy is true, the client routes through the proxy
|
||||
// defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables,
|
||||
// including any credentials embedded in those URLs. In that mode the proxy
|
||||
// owns DNS and egress, so destination dial pinning does not apply; the URL
|
||||
// allow/deny and IP-class validation still runs. Callers gate this behind
|
||||
// their module's opt-in flag. See
|
||||
// https://github.com/gotenberg/gotenberg/issues/1592.
|
||||
func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2.Regexp, enableEnvironmentProxy bool, opts ...DecideOption) *http.Client {
|
||||
base := http.DefaultTransport.(*http.Transport).Clone()
|
||||
|
||||
if enableEnvironmentProxy {
|
||||
// Route through the operator's proxy (standard env vars, credentials
|
||||
// included). NO_PROXY hosts get a direct, unpinned dial.
|
||||
// httpproxy.FromEnvironment reads the environment now rather than
|
||||
// caching it process-wide like http.ProxyFromEnvironment.
|
||||
proxyFunc := httpproxy.FromEnvironment().ProxyFunc()
|
||||
base.Proxy = func(req *http.Request) (*url.URL, error) {
|
||||
return proxyFunc(req.URL)
|
||||
}
|
||||
base.DialContext = outboundDialer.DialContext
|
||||
} else {
|
||||
// Default: ignore any proxy environment variables and pin the dial to
|
||||
// the IPs resolved during validation, closing the DNS-rebinding
|
||||
// window. Clearing Proxy is deliberate: the cloned default transport
|
||||
// carries http.ProxyFromEnvironment, which combined with the pinned
|
||||
// dialer would connect to the destination IP on the proxy's port.
|
||||
base.Proxy = nil
|
||||
base.DialContext = secureDialContext
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
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
|
||||
}
|
||||
|
||||
// DialThroughProxy opens a TCP tunnel to target (a host:port) through the
|
||||
// HTTP CONNECT proxy at proxyURL, authenticating with any credentials
|
||||
// embedded in proxyURL. dialProxy dials the proxy's own address; callers pass
|
||||
// a plain dialer. Chromium and soffice cannot authenticate to a proxy
|
||||
// themselves, so Gotenberg performs the CONNECT handshake on their behalf.
|
||||
// The returned connection carries the raw tunnel for the caller to splice
|
||||
// with the client. See https://github.com/gotenberg/gotenberg/issues/1592.
|
||||
func DialThroughProxy(ctx context.Context, proxyURL *url.URL, target string, dialProxy func(ctx context.Context, network, addr string) (net.Conn, error)) (net.Conn, error) {
|
||||
conn, err := dialProxy(ctx, "tcp", proxyHostPort(proxyURL))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial proxy: %w", err)
|
||||
}
|
||||
|
||||
if proxyURL.Scheme == "https" {
|
||||
tlsConn := tls.Client(conn, &tls.Config{ServerName: proxyURL.Hostname()})
|
||||
err = tlsConn.HandshakeContext(ctx)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("TLS handshake with proxy: %w", err)
|
||||
}
|
||||
conn = tlsConn
|
||||
}
|
||||
|
||||
// Bound the CONNECT handshake by the request deadline; cleared once the
|
||||
// tunnel is established so splicing manages its own lifetime.
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(deadline)
|
||||
}
|
||||
|
||||
connectReq := &http.Request{
|
||||
Method: http.MethodConnect,
|
||||
URL: &url.URL{Opaque: target},
|
||||
Host: target,
|
||||
Header: make(http.Header),
|
||||
}
|
||||
if user := proxyURL.User; user != nil {
|
||||
password, _ := user.Password()
|
||||
connectReq.Header.Set("Proxy-Authorization", proxyAuthHeader(user.Username(), password))
|
||||
}
|
||||
|
||||
err = connectReq.Write(conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("write CONNECT to proxy: %w", err)
|
||||
}
|
||||
|
||||
br := bufio.NewReader(conn)
|
||||
resp, err := http.ReadResponse(br, connectReq)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("read CONNECT response from proxy: %w", err)
|
||||
}
|
||||
// A CONNECT response carries no body; discard defensively.
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("proxy refused CONNECT to %q with status %d", target, resp.StatusCode)
|
||||
}
|
||||
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
|
||||
// The reader may hold bytes the proxy sent right after the response;
|
||||
// overlay it so those tunnel bytes are not lost when splicing.
|
||||
return &bufferedConn{Conn: conn, r: br}, nil
|
||||
}
|
||||
|
||||
// proxyHostPort returns proxyURL's host:port, defaulting the port from the
|
||||
// scheme when the URL omits it.
|
||||
func proxyHostPort(proxyURL *url.URL) string {
|
||||
port := proxyURL.Port()
|
||||
if port == "" {
|
||||
port = "80"
|
||||
if proxyURL.Scheme == "https" {
|
||||
port = "443"
|
||||
}
|
||||
}
|
||||
return net.JoinHostPort(proxyURL.Hostname(), port)
|
||||
}
|
||||
|
||||
// proxyAuthHeader builds a Basic Proxy-Authorization header value.
|
||||
func proxyAuthHeader(username, password string) string {
|
||||
return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))
|
||||
}
|
||||
|
||||
// bufferedConn overlays a [bufio.Reader] on a [net.Conn] so that bytes
|
||||
// buffered while reading a proxy's CONNECT response are not lost when the
|
||||
// tunnel is spliced.
|
||||
type bufferedConn struct {
|
||||
net.Conn
|
||||
r *bufio.Reader
|
||||
}
|
||||
|
||||
func (c *bufferedConn) Read(b []byte) (int, error) {
|
||||
return c.r.Read(b)
|
||||
}
|
||||
153
pkg/gotenberg/outbound_proxy_test.go
Normal file
153
pkg/gotenberg/outbound_proxy_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// connectCapture records the CONNECT request a proxy stub received.
|
||||
type connectCapture struct {
|
||||
mu sync.Mutex
|
||||
method string
|
||||
host string
|
||||
auth string
|
||||
}
|
||||
|
||||
func (c *connectCapture) set(method, host, auth string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.method, c.host, c.auth = method, host, auth
|
||||
}
|
||||
|
||||
func (c *connectCapture) get() (string, string, string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.method, c.host, c.auth
|
||||
}
|
||||
|
||||
// startConnectProxyStub starts a raw TCP server that behaves like an HTTP
|
||||
// CONNECT proxy: it reads the CONNECT request, records it, replies 200 with a
|
||||
// greeting appended to the same write (to exercise buffered-byte handling),
|
||||
// then echoes tunnel bytes back to the caller.
|
||||
func startConnectProxyStub(t *testing.T, capture *connectCapture) string {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = l.Close() })
|
||||
|
||||
go func() {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
br := bufio.NewReader(conn)
|
||||
req, err := http.ReadRequest(br)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
capture.set(req.Method, req.Host, req.Header.Get("Proxy-Authorization"))
|
||||
|
||||
// The greeting rides along with the response so the client's CONNECT
|
||||
// response parser buffers it; bufferedConn must not drop it.
|
||||
_, _ = conn.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\nTUNNEL-HELLO"))
|
||||
_, _ = io.Copy(conn, br)
|
||||
}()
|
||||
|
||||
return l.Addr().String()
|
||||
}
|
||||
|
||||
func TestDialThroughProxy(t *testing.T) {
|
||||
capture := &connectCapture{}
|
||||
addr := startConnectProxyStub(t, capture)
|
||||
|
||||
proxyURL := &url.URL{Scheme: "http", Host: addr, User: url.UserPassword("alice", "s3cr3t")}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
conn, err := DialThroughProxy(ctx, proxyURL, "example.com:443", func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, network, addr)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DialThroughProxy: %v", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
// The greeting buffered while reading the CONNECT response must survive.
|
||||
greeting := make([]byte, len("TUNNEL-HELLO"))
|
||||
_, err = io.ReadFull(conn, greeting)
|
||||
if err != nil {
|
||||
t.Fatalf("read greeting: %v", err)
|
||||
}
|
||||
if string(greeting) != "TUNNEL-HELLO" {
|
||||
t.Fatalf("greeting = %q, want TUNNEL-HELLO", greeting)
|
||||
}
|
||||
|
||||
// The tunnel must round-trip bytes.
|
||||
_, err = conn.Write([]byte("ping"))
|
||||
if err != nil {
|
||||
t.Fatalf("write to tunnel: %v", err)
|
||||
}
|
||||
echo := make([]byte, 4)
|
||||
_, err = io.ReadFull(conn, echo)
|
||||
if err != nil {
|
||||
t.Fatalf("read echo: %v", err)
|
||||
}
|
||||
if string(echo) != "ping" {
|
||||
t.Fatalf("echo = %q, want ping", echo)
|
||||
}
|
||||
|
||||
method, host, auth := capture.get()
|
||||
if method != http.MethodConnect {
|
||||
t.Fatalf("proxy saw method %q, want CONNECT", method)
|
||||
}
|
||||
if host != "example.com:443" {
|
||||
t.Fatalf("proxy saw target %q, want example.com:443", host)
|
||||
}
|
||||
wantAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("alice:s3cr3t"))
|
||||
if auth != wantAuth {
|
||||
t.Fatalf("proxy saw Proxy-Authorization %q, want %q", auth, wantAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialThroughProxy_RefusedStatus(t *testing.T) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = l.Close() })
|
||||
|
||||
go func() {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
br := bufio.NewReader(conn)
|
||||
_, _ = http.ReadRequest(br)
|
||||
_, _ = conn.Write([]byte("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n"))
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err = DialThroughProxy(ctx, &url.URL{Scheme: "http", Host: l.Addr().String()}, "example.com:443", func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, network, addr)
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when the proxy refuses CONNECT, got nil")
|
||||
}
|
||||
}
|
||||
489
pkg/gotenberg/outbound_test.go
Normal file
489
pkg/gotenberg/outbound_test.go
Normal file
@@ -0,0 +1,489 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
)
|
||||
|
||||
func TestIsPublicIP(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
addr string
|
||||
public bool
|
||||
}{
|
||||
// Public.
|
||||
{"1.1.1.1", true},
|
||||
{"8.8.8.8", true},
|
||||
{"2606:4700:4700::1111", true},
|
||||
|
||||
// Loopback.
|
||||
{"127.0.0.1", false},
|
||||
{"127.255.255.254", false},
|
||||
{"::1", false},
|
||||
|
||||
// IPv4-mapped IPv6 (Issue 2).
|
||||
{"::ffff:127.0.0.1", false},
|
||||
{"::ffff:10.0.0.1", false},
|
||||
{"::ffff:169.254.169.254", false},
|
||||
|
||||
// RFC1918.
|
||||
{"10.0.0.1", false},
|
||||
{"172.16.0.1", false},
|
||||
{"172.31.255.254", false},
|
||||
{"192.168.1.1", false},
|
||||
|
||||
// Link-local.
|
||||
{"169.254.169.254", false},
|
||||
{"fe80::1", false},
|
||||
|
||||
// Unique-local.
|
||||
{"fc00::1", false},
|
||||
{"fd12:3456:789a::1", false},
|
||||
|
||||
// Unspecified.
|
||||
{"0.0.0.0", false},
|
||||
{"::", false},
|
||||
|
||||
// Multicast.
|
||||
{"224.0.0.1", false},
|
||||
{"ff02::1", false},
|
||||
|
||||
// 6to4 wrapping internal/private IPv4 (RFC 3056, deprecated by
|
||||
// RFC 7526). a9fe:a9fe = 169.254.169.254 (cloud metadata).
|
||||
{"2002:a9fe:a9fe::", false},
|
||||
{"2002:0a00:0001::", false},
|
||||
{"2002:c0a8:0101::", false},
|
||||
|
||||
// 6to4 wrapping a public IPv4 (8.8.8.8) is rejected wholesale.
|
||||
{"2002:0808:0808::", false},
|
||||
|
||||
// NAT64 well-known prefix (RFC 6052).
|
||||
{"64:ff9b::a9fe:a9fe", false},
|
||||
{"64:ff9b::0808:0808", false},
|
||||
|
||||
// NAT64 local-use prefix (RFC 8215).
|
||||
{"64:ff9b:1::a9fe:a9fe", false},
|
||||
|
||||
// Teredo (RFC 4380).
|
||||
{"2001:0:abcd:ef12:3456:7890:a9fe:a9fe", false},
|
||||
|
||||
// Deprecated site-local (RFC 3879).
|
||||
{"fec0::1", false},
|
||||
{"feff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", false},
|
||||
|
||||
// IPv4-compatible IPv6 (deprecated, not handled by Unmap).
|
||||
{"::a9fe:a9fe", false},
|
||||
|
||||
// Documentation prefix (RFC 3849).
|
||||
{"2001:db8::1", false},
|
||||
|
||||
// Discard prefix (RFC 6666).
|
||||
{"100::1", false},
|
||||
} {
|
||||
t.Run(tc.addr, func(t *testing.T) {
|
||||
addr, err := netip.ParseAddr(tc.addr)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", tc.addr, err)
|
||||
}
|
||||
if got := IsPublicIP(addr); got != tc.public {
|
||||
t.Fatalf("IsPublicIP(%q) = %v, want %v", tc.addr, got, tc.public)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// stubResolver lets tests fake DNS lookups in [ResolveAndCheckPublic].
|
||||
type stubResolver struct {
|
||||
lookup func(host string) ([]netip.Addr, error)
|
||||
}
|
||||
|
||||
func (s stubResolver) LookupNetIP(_ context.Context, _, host string) ([]netip.Addr, error) {
|
||||
return s.lookup(host)
|
||||
}
|
||||
|
||||
func withStubResolver(t *testing.T, fn func(host string) ([]netip.Addr, error)) {
|
||||
t.Helper()
|
||||
prev := outboundResolver
|
||||
outboundResolver = stubResolver{lookup: fn}
|
||||
t.Cleanup(func() { outboundResolver = prev })
|
||||
}
|
||||
|
||||
func mustAddrs(t *testing.T, ss ...string) []netip.Addr {
|
||||
t.Helper()
|
||||
out := make([]netip.Addr, 0, len(ss))
|
||||
for _, s := range ss {
|
||||
a, err := netip.ParseAddr(s)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", s, err)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestFilterOutboundURL(t *testing.T) {
|
||||
defaultDeny := []*regexp2.Regexp{
|
||||
regexp2.MustCompile(`^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)`, 0),
|
||||
}
|
||||
chromiumDeny := []*regexp2.Regexp{
|
||||
regexp2.MustCompile(`^file:(?!//\/tmp/).*`, 0),
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
rawURL string
|
||||
allow []*regexp2.Regexp
|
||||
deny []*regexp2.Regexp
|
||||
opts []DecideOption
|
||||
stub func(host string) ([]netip.Addr, error)
|
||||
expectErr bool
|
||||
expectIs error
|
||||
expectErrMsg string
|
||||
}{
|
||||
{
|
||||
scenario: "public IP literal passes",
|
||||
rawURL: "https://1.1.1.1/",
|
||||
deny: defaultDeny,
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
scenario: "loopback IP literal blocked by default deny-list",
|
||||
rawURL: "http://127.0.0.1:8080/",
|
||||
deny: defaultDeny,
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "Issue 4: uppercase scheme normalized then blocked by deny-list",
|
||||
rawURL: "HTTP://127.0.0.1:8080/",
|
||||
deny: defaultDeny,
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "Issue 2: IPv4-mapped IPv6 evades deny-list but blocked by IP check",
|
||||
rawURL: "http://[::ffff:127.0.0.1]:8080/page.pdf",
|
||||
deny: defaultDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "Issue 2: IPv4-mapped IPv6 to RFC1918 blocked by IP check",
|
||||
rawURL: "http://[::ffff:10.0.0.1]/",
|
||||
deny: defaultDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "hostname resolving to public IP passes with deny-private-ips",
|
||||
rawURL: "https://example.com/",
|
||||
deny: defaultDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "93.184.216.34"), nil },
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
scenario: "hostname resolving to loopback blocked with deny-private-ips",
|
||||
rawURL: "https://rebind.example/",
|
||||
deny: defaultDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "127.0.0.1"), nil },
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "hostname resolving to mixed public+private blocked with deny-private-ips",
|
||||
rawURL: "https://mixed.example/",
|
||||
deny: defaultDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "1.1.1.1", "10.0.0.1"), nil },
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "allow-list match bypasses IP check",
|
||||
rawURL: "http://internal.service/api",
|
||||
allow: []*regexp2.Regexp{regexp2.MustCompile(`^http://internal\.service`, 0)},
|
||||
deny: defaultDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
scenario: "deny-list still wins over allow-list match",
|
||||
rawURL: "http://internal.service/api",
|
||||
allow: []*regexp2.Regexp{regexp2.MustCompile(`^http://internal`, 0)},
|
||||
deny: []*regexp2.Regexp{regexp2.MustCompile(`/api$`, 0)},
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "allow-list non-empty and no match rejects",
|
||||
rawURL: "https://other.example/",
|
||||
allow: []*regexp2.Regexp{regexp2.MustCompile(`^https://allowed\.example`, 0)},
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "file:// allowed under tmp passes Chromium default",
|
||||
rawURL: "file:///tmp/index.html",
|
||||
deny: chromiumDeny,
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
scenario: "file:// outside tmp blocked by Chromium default",
|
||||
rawURL: "file:///etc/passwd",
|
||||
deny: chromiumDeny,
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "Chromium default permissive passes http to public host",
|
||||
rawURL: "https://example.com/",
|
||||
deny: chromiumDeny,
|
||||
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "93.184.216.34"), nil },
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
scenario: "Chromium with deny-private-ips blocks http to loopback",
|
||||
rawURL: "http://127.0.0.1:3000/health",
|
||||
deny: chromiumDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "Chromium with deny-private-ips blocks cloud metadata",
|
||||
rawURL: "http://169.254.169.254/latest/meta-data/",
|
||||
deny: chromiumDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "data: URL passes (non-network scheme)",
|
||||
rawURL: "data:text/html;base64,PGgxPmhpPC9oMT4=",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
scenario: "URL with no host rejected",
|
||||
rawURL: "http:///path",
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "userinfo cannot mask host when deny-private-ips enabled",
|
||||
rawURL: "http://example.com@127.0.0.1/",
|
||||
deny: defaultDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.stub != nil {
|
||||
withStubResolver(t, tc.stub)
|
||||
} else {
|
||||
// Default: any DNS lookup in a non-stubbed test is a bug.
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
t.Fatalf("unexpected DNS lookup for %q", host)
|
||||
return nil, nil
|
||||
})
|
||||
}
|
||||
|
||||
err := FilterOutboundURL(context.Background(), tc.rawURL, tc.allow, tc.deny, time.Now().Add(5*time.Second), tc.opts...)
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if tc.expectIs != nil && !errors.Is(err, tc.expectIs) {
|
||||
t.Fatalf("expected error to wrap %v, got: %v", tc.expectIs, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAndCheckPublic_IPLiteralLoopback(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
t.Fatalf("unexpected DNS lookup for %q", host)
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
_, err := ResolveAndCheckPublic(context.Background(), "127.0.0.1")
|
||||
if !errors.Is(err, ErrNonPublicIP) {
|
||||
t.Fatalf("expected ErrNonPublicIP, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAndCheckPublic_HostResolvesToLoopback(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
return mustAddrs(t, "127.0.0.1"), nil
|
||||
})
|
||||
|
||||
_, err := ResolveAndCheckPublic(context.Background(), "rebind.example")
|
||||
if !errors.Is(err, ErrNonPublicIP) {
|
||||
t.Fatalf("expected ErrNonPublicIP, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAndCheckPublic_HostResolvesToPublic(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
return mustAddrs(t, "1.1.1.1"), nil
|
||||
})
|
||||
|
||||
addrs, err := ResolveAndCheckPublic(context.Background(), "example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(addrs) != 1 || addrs[0].String() != "1.1.1.1" {
|
||||
t.Fatalf("expected [1.1.1.1], got: %v", addrs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideOutbound_DenyPrivateIPs_RejectsLoopbackLiteral(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
t.Fatalf("unexpected DNS lookup for %q", host)
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
_, err := DecideOutbound(
|
||||
context.Background(),
|
||||
"http://127.0.0.1:8080/",
|
||||
nil, nil,
|
||||
time.Now().Add(5*time.Second),
|
||||
WithDenyPrivateIPs(true),
|
||||
)
|
||||
if !errors.Is(err, ErrFiltered) {
|
||||
t.Fatalf("WithDenyPrivateIPs(true) must reject loopback literal, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideOutbound_DenyPrivateIPs_AllowsPublic(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
return mustAddrs(t, "93.184.216.34"), nil
|
||||
})
|
||||
|
||||
decision, err := DecideOutbound(
|
||||
context.Background(),
|
||||
"http://example.com/",
|
||||
nil, nil,
|
||||
time.Now().Add(5*time.Second),
|
||||
WithDenyPrivateIPs(true),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for public host, got: %v", err)
|
||||
}
|
||||
if len(decision.Pinned) != 1 || decision.Pinned[0].String() != "93.184.216.34" {
|
||||
t.Fatalf("decision.Pinned = %v, want [93.184.216.34]", decision.Pinned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideOutbound_DenyPublicIPs_RejectsPublic(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
return mustAddrs(t, "1.1.1.1"), nil
|
||||
})
|
||||
|
||||
_, err := DecideOutbound(
|
||||
context.Background(),
|
||||
"http://example.com/",
|
||||
nil, nil,
|
||||
time.Now().Add(5*time.Second),
|
||||
WithDenyPublicIPs(true),
|
||||
)
|
||||
if !errors.Is(err, ErrFiltered) {
|
||||
t.Fatalf("WithDenyPublicIPs(true) must reject public host, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideOutbound_DenyPublicIPs_AllowsPrivate(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
return mustAddrs(t, "10.0.0.5"), nil
|
||||
})
|
||||
|
||||
decision, err := DecideOutbound(
|
||||
context.Background(),
|
||||
"http://internal.svc/",
|
||||
nil, nil,
|
||||
time.Now().Add(5*time.Second),
|
||||
WithDenyPublicIPs(true),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for private host, got: %v", err)
|
||||
}
|
||||
if len(decision.Pinned) != 1 || decision.Pinned[0].String() != "10.0.0.5" {
|
||||
t.Fatalf("decision.Pinned = %v, want [10.0.0.5]", decision.Pinned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideOutbound_DenyBoth_WhitelistOnly(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
return mustAddrs(t, "1.1.1.1"), nil
|
||||
})
|
||||
|
||||
// Both denies active and no allow-list match: every resolved address
|
||||
// fails. Only an allow-list match can permit a destination under
|
||||
// this posture.
|
||||
_, err := DecideOutbound(
|
||||
context.Background(),
|
||||
"http://example.com/",
|
||||
nil, nil,
|
||||
time.Now().Add(5*time.Second),
|
||||
WithDenyPrivateIPs(true),
|
||||
WithDenyPublicIPs(true),
|
||||
)
|
||||
if !errors.Is(err, ErrFiltered) {
|
||||
t.Fatalf("expected ErrFiltered with both denies enabled, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideOutbound_DenyLists_WinOverDenyPrivateIPs(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
t.Fatalf("unexpected DNS lookup for %q", host)
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
// The regex deny-list fires before any resolution; verifies that
|
||||
// operator-supplied deny patterns remain effective regardless of
|
||||
// IP-class options.
|
||||
deny := []*regexp2.Regexp{regexp2.MustCompile(`^http://evil\.`, 0)}
|
||||
|
||||
_, err := DecideOutbound(
|
||||
context.Background(),
|
||||
"http://evil.local/",
|
||||
nil, deny,
|
||||
time.Now().Add(5*time.Second),
|
||||
WithDenyPrivateIPs(true),
|
||||
)
|
||||
if !errors.Is(err, ErrFiltered) {
|
||||
t.Fatalf("deny-list must still reject, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideOutbound_Permissive_AllowsPrivate(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
return mustAddrs(t, "10.0.0.5"), nil
|
||||
})
|
||||
|
||||
// No options passed: default posture is permissive across both
|
||||
// IP classes. The caller still gets pinned IPs for dial safety.
|
||||
decision, err := DecideOutbound(
|
||||
context.Background(),
|
||||
"http://internal.svc/",
|
||||
nil, nil,
|
||||
time.Now().Add(5*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("permissive default must allow private host, got: %v", err)
|
||||
}
|
||||
if len(decision.Pinned) != 1 || decision.Pinned[0].String() != "10.0.0.5" {
|
||||
t.Fatalf("decision.Pinned = %v, want [10.0.0.5]", decision.Pinned)
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,10 @@ var (
|
||||
// ErrPdfRotateAngleNotSupported is returned when the rotation angle is
|
||||
// not supported.
|
||||
ErrPdfRotateAngleNotSupported = errors.New("rotation angle not supported")
|
||||
|
||||
// ErrPdfFacturXValueNotSupported is returned when a Factur-X field value
|
||||
// (e.g., an unknown conformance level or document type) is not supported.
|
||||
ErrPdfFacturXValueNotSupported = errors.New("Factur-X value not supported")
|
||||
)
|
||||
|
||||
// PdfEngineInvalidArgsError represents an error returned by a PDF engine when
|
||||
@@ -143,6 +147,56 @@ type PdfFormats struct {
|
||||
PdfUa bool
|
||||
}
|
||||
|
||||
// PdfPermissions gathers the document permissions enforced when a PDF is
|
||||
// encrypted. Each field defaults to true (the action is allowed); set a field
|
||||
// to false to restrict it. Restrictions are advisory: viewers honor them, but
|
||||
// they are not cryptographically enforced once the document opens.
|
||||
type PdfPermissions struct {
|
||||
// AllowPrinting permits printing the document.
|
||||
AllowPrinting bool
|
||||
|
||||
// AllowCopying permits extracting text and graphics.
|
||||
AllowCopying bool
|
||||
|
||||
// AllowModifying permits changing the document content.
|
||||
AllowModifying bool
|
||||
|
||||
// AllowAnnotating permits adding or modifying annotations.
|
||||
AllowAnnotating bool
|
||||
|
||||
// AllowFillingForms permits filling in form fields.
|
||||
AllowFillingForms bool
|
||||
|
||||
// AllowAssembling permits inserting, deleting, and rotating pages.
|
||||
AllowAssembling bool
|
||||
}
|
||||
|
||||
// Restricted reports whether at least one permission is denied.
|
||||
func (p PdfPermissions) Restricted() bool {
|
||||
return !p.AllowPrinting ||
|
||||
!p.AllowCopying ||
|
||||
!p.AllowModifying ||
|
||||
!p.AllowAnnotating ||
|
||||
!p.AllowFillingForms ||
|
||||
!p.AllowAssembling
|
||||
}
|
||||
|
||||
// EncryptOptions gathers the parameters for encrypting a PDF. An empty
|
||||
// UserPassword with a set OwnerPassword produces an owner-only document: it
|
||||
// opens without a password but enforces the [PdfPermissions].
|
||||
type EncryptOptions struct {
|
||||
// UserPassword is required to open the document. Empty means no open
|
||||
// password.
|
||||
UserPassword string
|
||||
|
||||
// OwnerPassword grants full access (lifts the permission restrictions).
|
||||
// When empty, it defaults to UserPassword.
|
||||
OwnerPassword string
|
||||
|
||||
// Permissions are the actions allowed when opened with the user password.
|
||||
Permissions PdfPermissions
|
||||
}
|
||||
|
||||
// Bookmark represents a node in the PDF document's outline
|
||||
// (table of contents).
|
||||
type Bookmark struct {
|
||||
@@ -151,6 +205,59 @@ type Bookmark struct {
|
||||
Children []Bookmark `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
// FacturXConformanceMinimum represents the MINIMUM Factur-X conformance level.
|
||||
FacturXConformanceMinimum string = "MINIMUM"
|
||||
|
||||
// FacturXConformanceBasicWL represents the BASIC WL Factur-X conformance level.
|
||||
FacturXConformanceBasicWL string = "BASIC WL"
|
||||
|
||||
// FacturXConformanceBasic represents the BASIC Factur-X conformance level.
|
||||
FacturXConformanceBasic string = "BASIC"
|
||||
|
||||
// FacturXConformanceEN16931 represents the EN 16931 Factur-X conformance level.
|
||||
FacturXConformanceEN16931 string = "EN 16931"
|
||||
|
||||
// FacturXConformanceExtended represents the EXTENDED Factur-X conformance level.
|
||||
FacturXConformanceExtended string = "EXTENDED"
|
||||
|
||||
// FacturXConformanceXRechnung represents the XRECHNUNG Factur-X conformance level.
|
||||
FacturXConformanceXRechnung string = "XRECHNUNG"
|
||||
|
||||
// FacturXDocumentTypeInvoice represents the INVOICE Factur-X document type.
|
||||
FacturXDocumentTypeInvoice string = "INVOICE"
|
||||
|
||||
// FacturXDocumentTypeOrder represents the ORDER Factur-X document type.
|
||||
FacturXDocumentTypeOrder string = "ORDER"
|
||||
|
||||
// FacturXDocumentTypeOrderResponse represents the ORDER_RESPONSE Factur-X document type.
|
||||
FacturXDocumentTypeOrderResponse string = "ORDER_RESPONSE"
|
||||
|
||||
// FacturXDocumentTypeOrderChange represents the ORDER_CHANGE Factur-X document type.
|
||||
FacturXDocumentTypeOrderChange string = "ORDER_CHANGE"
|
||||
|
||||
// FacturXDocumentFileName is the canonical name of the embedded XML invoice
|
||||
// mandated by the Factur-X standard. Validators expect this exact name.
|
||||
FacturXDocumentFileName string = "factur-x.xml"
|
||||
)
|
||||
|
||||
// FacturX gathers the properties required by the Factur-X/ZUGFeRD standard for
|
||||
// the document-level XMP metadata packet of a PDF/A-3.
|
||||
type FacturX struct {
|
||||
// ConformanceLevel is one of the FacturXConformance* values.
|
||||
ConformanceLevel string
|
||||
|
||||
// DocumentType is one of the FacturXDocumentType* values.
|
||||
DocumentType string
|
||||
|
||||
// DocumentFileName is the name of the embedded XML invoice. It is set
|
||||
// internally to the canonical [FacturXDocumentFileName], not by the caller.
|
||||
DocumentFileName string
|
||||
|
||||
// Version is the Factur-X version (e.g., "1.0").
|
||||
Version string
|
||||
}
|
||||
|
||||
// PdfEngine provides an interface for operations on PDFs. Implementations
|
||||
// can use various tools like PDFtk, or implement functionality directly in
|
||||
// Go.
|
||||
@@ -190,17 +297,23 @@ type PdfEngine interface {
|
||||
// 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
|
||||
// Encrypt adds password protection and permission restrictions to a PDF
|
||||
// file, as described by [EncryptOptions]. An empty user password with a set
|
||||
// owner password yields an owner-only document (opens without a password,
|
||||
// permissions enforced).
|
||||
Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts EncryptOptions) error
|
||||
|
||||
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
|
||||
// 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
|
||||
|
||||
@@ -210,6 +323,18 @@ type PdfEngine interface {
|
||||
// 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
|
||||
|
||||
// InjectFacturXXMP injects Factur-X/ZUGFeRD XMP metadata into the
|
||||
// document-level XMP packet (Catalog /Metadata stream) of a PDF/A-3. It
|
||||
// registers the fx namespace, the four fx properties, and the matching
|
||||
// PDF/A extension schema so the result stays PDF/A-valid.
|
||||
InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX FacturX, inputPath string) error
|
||||
|
||||
// ReadPdfAConformance reads the PDF/A part and conformance (e.g., "3" and
|
||||
// "B") from the document-level XMP packet (Catalog /Metadata stream,
|
||||
// pdfaid:part and pdfaid:conformance). It returns empty strings when the
|
||||
// document carries no PDF/A identification.
|
||||
ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (part string, conformance string, err error)
|
||||
}
|
||||
|
||||
// PdfEngineProvider offers an interface to instantiate a [PdfEngine].
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// Package semconv is a copy/paste of utilities that are currently not exposed
|
||||
// in the OpenTelemery Go SDK.
|
||||
// Package semconv contains utilities not yet exposed in the OpenTelemetry Go
|
||||
// SDK. Remove this package once an official API exists.
|
||||
//
|
||||
// This package MUST be removed once an "official" API is provided.
|
||||
//
|
||||
// See: https://github.com/open-telemetry/opentelemetry-go-contrib/issues/4580.
|
||||
// See https://github.com/open-telemetry/opentelemetry-go-contrib/issues/4580.
|
||||
package semconv
|
||||
|
||||
@@ -8,6 +8,10 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// ErrProcessAlreadyRestarting happens if the [ProcessSupervisor] is trying
|
||||
@@ -76,22 +80,45 @@ type ProcessSupervisor interface {
|
||||
|
||||
// ActiveTasksCount returns the current number of active tasks.
|
||||
ActiveTasksCount() int64
|
||||
|
||||
// ConversionsSinceRestart returns the number of tasks handled since the
|
||||
// last process (re)start.
|
||||
ConversionsSinceRestart() int64
|
||||
}
|
||||
|
||||
// healthCheckCacheTTL caches successful health probe results so kubelet-
|
||||
// style probes (liveness + readiness, every few seconds each) do not
|
||||
// hammer the underlying process with CDP roundtrips on every call.
|
||||
// Tuned to bridge typical probe periods while still catching outages
|
||||
// quickly: a real outage surfaces on the next probe after the TTL
|
||||
// elapses.
|
||||
const healthCheckCacheTTL = 2 * time.Second
|
||||
|
||||
// healthFailureThreshold is the number of consecutive Healthy() failures
|
||||
// the supervisor tolerates before reporting unhealthy. Absorbs single-
|
||||
// probe blips of transient CDP latency (for example a slow
|
||||
// Browser.getVersion roundtrip when several conversion slots are
|
||||
// simultaneously stuck), without delaying detection of a real outage.
|
||||
// The container orchestrator's own failureThreshold stacks on top of
|
||||
// this. See https://github.com/gotenberg/gotenberg/issues/1561.
|
||||
const healthFailureThreshold = 2
|
||||
|
||||
type processSupervisor struct {
|
||||
logger *slog.Logger
|
||||
engine string
|
||||
process Process
|
||||
maxReqLimit int64
|
||||
maxQueueSize int64
|
||||
maxConcurrency int64
|
||||
semaphore chan struct{}
|
||||
firstStart atomic.Bool
|
||||
firstStartOnce sync.Once
|
||||
// firstStartErr stores the error from the first Launch attempt executed
|
||||
// via firstStartOnce. Subsequent callers that enter the !firstStart block
|
||||
// need to observe this value after the Once has completed, without
|
||||
// re-executing the closure.
|
||||
firstStartErr error
|
||||
// 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
|
||||
@@ -99,19 +126,32 @@ type processSupervisor struct {
|
||||
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
|
||||
lastActivity atomic.Int64 // unix nano timestamp of last completed task
|
||||
// healthMu serializes Healthy() probes so concurrent callers do not
|
||||
// all issue a CDP roundtrip; the second caller hits the refreshed
|
||||
// cache instead.
|
||||
healthMu sync.Mutex
|
||||
lastHealthyAt atomic.Int64 // unix nano of last successful probe; 0 means never
|
||||
consecutiveHealthFailures atomic.Int64 // reset to 0 on every successful probe
|
||||
idleMu sync.Mutex // protects idleStopChan
|
||||
idleStopChan chan struct{} // signal to stop the idle ticker goroutine
|
||||
}
|
||||
|
||||
// NewProcessSupervisor initializes a new [ProcessSupervisor].
|
||||
func NewProcessSupervisor(logger *slog.Logger, process Process, maxReqLimit, maxQueueSize, maxConcurrency int64, idleShutdownTimeout time.Duration) ProcessSupervisor {
|
||||
// NewProcessSupervisor initializes a new [ProcessSupervisor]. engine names the
|
||||
// managed process (for example "chromium" or "libreoffice") and prefixes the
|
||||
// telemetry sub-spans; an empty engine falls back to "process".
|
||||
func NewProcessSupervisor(logger *slog.Logger, engine string, process Process, maxReqLimit, maxQueueSize, maxConcurrency int64, idleShutdownTimeout time.Duration) ProcessSupervisor {
|
||||
if maxConcurrency < 1 {
|
||||
maxConcurrency = 1
|
||||
}
|
||||
|
||||
if engine == "" {
|
||||
engine = "process"
|
||||
}
|
||||
|
||||
b := &processSupervisor{
|
||||
logger: logger,
|
||||
engine: engine,
|
||||
process: process,
|
||||
semaphore: make(chan struct{}, maxConcurrency),
|
||||
maxReqLimit: maxReqLimit,
|
||||
@@ -194,15 +234,69 @@ func (s *processSupervisor) Healthy() bool {
|
||||
}
|
||||
|
||||
if s.isRestarting.Load() {
|
||||
// A restarting process is not yet healthy — this gives load balancers
|
||||
// A restarting process is not yet healthy. This gives load balancers
|
||||
// honest information so they can avoid routing traffic to this node.
|
||||
return false
|
||||
}
|
||||
|
||||
return s.process.Healthy(s.logger)
|
||||
// Cache hit: a recent probe succeeded. Skip the CDP roundtrip so probe
|
||||
// spam does not pile commands onto a busy websocket.
|
||||
if s.recentlyHealthy() {
|
||||
return true
|
||||
}
|
||||
|
||||
// Serialize probes so concurrent callers do not all roundtrip. The
|
||||
// second caller will see the refreshed cache (or counter) and return
|
||||
// without re-probing.
|
||||
s.healthMu.Lock()
|
||||
defer s.healthMu.Unlock()
|
||||
|
||||
if s.recentlyHealthy() {
|
||||
return true
|
||||
}
|
||||
|
||||
if s.process.Healthy(s.logger) {
|
||||
s.lastHealthyAt.Store(time.Now().UnixNano())
|
||||
s.consecutiveHealthFailures.Store(0)
|
||||
return true
|
||||
}
|
||||
|
||||
if s.consecutiveHealthFailures.Add(1) < healthFailureThreshold {
|
||||
// First failure: tolerate it. Under load, a single blown CDP
|
||||
// timeout is more likely transient pressure than a dead process.
|
||||
// A genuinely dead process will fail the next probe as well and
|
||||
// flip us unhealthy then.
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// recentlyHealthy reports whether a successful probe landed within
|
||||
// [healthCheckCacheTTL]. Negative results are never cached so recovery
|
||||
// from a real outage is observable on the very next probe.
|
||||
func (s *processSupervisor) recentlyHealthy() bool {
|
||||
last := s.lastHealthyAt.Load()
|
||||
if last == 0 {
|
||||
return false
|
||||
}
|
||||
return time.Since(time.Unix(0, last)) < healthCheckCacheTTL
|
||||
}
|
||||
|
||||
func (s *processSupervisor) Run(ctx context.Context, logger *slog.Logger, task func() error) error {
|
||||
// Time spent before the task body runs: queueing, slot acquisition, lazy
|
||||
// launch, and health checks. Ended once, when the task is about to execute.
|
||||
_, queueSpan := Tracer().Start(ctx, s.engine+".queue.wait",
|
||||
trace.WithSpanKind(trace.SpanKindInternal),
|
||||
)
|
||||
queueWaitDone := false
|
||||
endQueueWait := func() {
|
||||
if !queueWaitDone {
|
||||
queueWaitDone = true
|
||||
queueSpan.End()
|
||||
}
|
||||
}
|
||||
defer endQueueWait()
|
||||
|
||||
// Atomically check and increment the queue size to avoid the TOCTOU race
|
||||
// originally reported in https://github.com/gotenberg/gotenberg/issues/951.
|
||||
for {
|
||||
@@ -251,6 +345,7 @@ func (s *processSupervisor) Run(ctx context.Context, logger *slog.Logger, task f
|
||||
return err
|
||||
}
|
||||
|
||||
endQueueWait()
|
||||
err := s.runWithDeadline(ctx, task)
|
||||
|
||||
if s.maybeRestartAfterTask(logger) {
|
||||
@@ -333,7 +428,7 @@ func (s *processSupervisor) maybeIdleShutdown() {
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.InfoContext(context.Background(), "idle shutdown timeout reached, stopping process")
|
||||
s.logger.DebugContext(context.Background(), "idle shutdown timeout reached, stopping process")
|
||||
|
||||
// Stop the ticker — it will be restarted on the next Launch().
|
||||
s.stopIdleTicker()
|
||||
@@ -346,11 +441,9 @@ func (s *processSupervisor) maybeIdleShutdown() {
|
||||
|
||||
// Reset state so ensureStarted() re-launches on next request.
|
||||
s.firstStart.Store(false)
|
||||
s.firstStartOnce = sync.Once{}
|
||||
s.firstStartErr = nil
|
||||
s.reqCounter.Store(0)
|
||||
|
||||
s.logger.InfoContext(context.Background(), "process stopped due to idle timeout")
|
||||
s.logger.DebugContext(context.Background(), "process stopped due to idle timeout")
|
||||
}
|
||||
|
||||
// acquireSlot attempts to acquire a semaphore slot, yielding it back if a
|
||||
@@ -375,23 +468,50 @@ func (s *processSupervisor) acquireSlot(ctx context.Context, logger *slog.Logger
|
||||
}
|
||||
}
|
||||
|
||||
// ensureStarted performs a one-time lazy launch of the process on its first
|
||||
// use. Subsequent calls are no-ops.
|
||||
// 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.firstStartOnce.Do(func() {
|
||||
s.firstStartErr = s.runWithDeadline(ctx, func() error {
|
||||
return s.Launch()
|
||||
})
|
||||
})
|
||||
s.firstStartMu.Lock()
|
||||
defer s.firstStartMu.Unlock()
|
||||
|
||||
if s.firstStartErr != nil {
|
||||
return fmt.Errorf("process first start: %w", s.firstStartErr)
|
||||
if s.firstStart.Load() {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := s.tracedLaunch(ctx, "first_start", func() error {
|
||||
return s.runWithDeadline(ctx, s.Launch)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("process first start: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// tracedLaunch wraps a process (re)start in an <engine>.process.start span,
|
||||
// tagged with the reason that triggered it. The eager restart after the maximum
|
||||
// request limit runs on a background context, so its span is a detached root.
|
||||
func (s *processSupervisor) tracedLaunch(ctx context.Context, reason string, launch func() error) error {
|
||||
_, span := Tracer().Start(ctx, s.engine+".process.start",
|
||||
trace.WithSpanKind(trace.SpanKindInternal),
|
||||
trace.WithAttributes(attribute.String("gotenberg.process.start.reason", reason)),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
err := launch()
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -405,7 +525,7 @@ func (s *processSupervisor) ensureHealthy(ctx context.Context) error {
|
||||
|
||||
s.logger.DebugContext(context.Background(), "process is unhealthy, cannot handle task, restarting...")
|
||||
|
||||
if err := s.doRestart(ctx); err != nil {
|
||||
if err := s.doRestart(ctx, "unhealthy"); err != nil {
|
||||
return fmt.Errorf("process restart before task: %w", err)
|
||||
}
|
||||
|
||||
@@ -428,7 +548,7 @@ func (s *processSupervisor) maybeRestartAfterTask(logger *slog.Logger) bool {
|
||||
s.logger.DebugContext(context.Background(), "max request limit reached, restarting eagerly...")
|
||||
|
||||
go func() {
|
||||
restartErr := s.doRestartLocked(context.Background())
|
||||
restartErr := s.doRestartLocked(context.Background(), "max_requests")
|
||||
s.restartMutex.Unlock()
|
||||
if restartErr != nil {
|
||||
s.logger.ErrorContext(context.Background(), fmt.Sprintf("process restart after task: %v", restartErr))
|
||||
@@ -442,15 +562,15 @@ func (s *processSupervisor) maybeRestartAfterTask(logger *slog.Logger) bool {
|
||||
|
||||
// doRestart coordinates a process restart, draining all active concurrent
|
||||
// tasks before stopping and restarting the process.
|
||||
func (s *processSupervisor) doRestart(ctx context.Context) error {
|
||||
func (s *processSupervisor) doRestart(ctx context.Context, reason string) error {
|
||||
s.restartMutex.Lock()
|
||||
defer s.restartMutex.Unlock()
|
||||
|
||||
return s.doRestartLocked(ctx)
|
||||
return s.doRestartLocked(ctx, reason)
|
||||
}
|
||||
|
||||
// doRestartLocked performs the restart drain logic. The caller must hold restartMutex.
|
||||
func (s *processSupervisor) doRestartLocked(ctx context.Context) error {
|
||||
func (s *processSupervisor) doRestartLocked(ctx context.Context, reason string) error {
|
||||
s.isRestarting.Store(true)
|
||||
defer s.isRestarting.Store(false)
|
||||
|
||||
@@ -470,8 +590,8 @@ func (s *processSupervisor) doRestartLocked(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
err := s.runWithDeadline(ctx, func() error {
|
||||
return s.restart()
|
||||
err := s.tracedLaunch(ctx, reason, func() error {
|
||||
return s.runWithDeadline(ctx, s.restart)
|
||||
})
|
||||
|
||||
for range acquired {
|
||||
@@ -509,6 +629,13 @@ func (s *processSupervisor) ActiveTasksCount() int64 {
|
||||
return s.activeTasks.Load()
|
||||
}
|
||||
|
||||
// ConversionsSinceRestart returns the number of tasks handled since the last
|
||||
// process (re)start. reqCounter is reset to zero on every restart and idle
|
||||
// shutdown.
|
||||
func (s *processSupervisor) ConversionsSinceRestart() int64 {
|
||||
return s.reqCounter.Load()
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ ProcessSupervisor = (*processSupervisor)(nil)
|
||||
|
||||
@@ -45,7 +45,7 @@ func TestProcessSupervisor_Launch(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
|
||||
if tc.firstStartSet {
|
||||
ps.firstStart.Store(true)
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func TestProcessSupervisor_Shutdown(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0)
|
||||
err := ps.Shutdown()
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
@@ -145,7 +145,7 @@ func TestProcessSupervisor_restart(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
|
||||
|
||||
err := ps.restart()
|
||||
|
||||
@@ -186,10 +186,10 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
|
||||
expectHealthy: true,
|
||||
},
|
||||
{
|
||||
scenario: "process reports as unhealthy",
|
||||
scenario: "single probe failure is tolerated",
|
||||
initiallyStarted: true,
|
||||
processHealthy: false,
|
||||
expectHealthy: false,
|
||||
expectHealthy: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
@@ -201,7 +201,7 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
|
||||
if tc.initiallyStarted {
|
||||
ps.firstStart.Store(true)
|
||||
}
|
||||
@@ -218,6 +218,109 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessSupervisor_Healthy_ConsecutiveFailures verifies that only
|
||||
// the second consecutive process-level failure flips the supervisor to
|
||||
// unhealthy, and that a single success in between resets the counter.
|
||||
func TestProcessSupervisor_Healthy_ConsecutiveFailures(t *testing.T) {
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
var processHealthy atomic.Bool
|
||||
process := &ProcessMock{
|
||||
HealthyMock: func(_ *slog.Logger) bool { return processHealthy.Load() },
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
|
||||
ps.firstStart.Store(true)
|
||||
|
||||
processHealthy.Store(false)
|
||||
if !ps.Healthy() {
|
||||
t.Fatal("first failure should be tolerated and report healthy")
|
||||
}
|
||||
if ps.Healthy() {
|
||||
t.Fatal("second consecutive failure should report unhealthy")
|
||||
}
|
||||
|
||||
processHealthy.Store(true)
|
||||
if !ps.Healthy() {
|
||||
t.Fatal("recovery should report healthy immediately")
|
||||
}
|
||||
|
||||
processHealthy.Store(false)
|
||||
// Cache hit from the previous success absorbs the first new failure;
|
||||
// invalidate it so we exercise the counter again.
|
||||
ps.lastHealthyAt.Store(0)
|
||||
if !ps.Healthy() {
|
||||
t.Fatal("post-recovery first failure should be tolerated again")
|
||||
}
|
||||
if ps.Healthy() {
|
||||
t.Fatal("post-recovery second consecutive failure should report unhealthy")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessSupervisor_Healthy_CachesPositiveResult verifies that a
|
||||
// successful probe is cached for [healthCheckCacheTTL] so subsequent
|
||||
// supervisor.Healthy() calls do not re-issue the underlying process
|
||||
// check.
|
||||
func TestProcessSupervisor_Healthy_CachesPositiveResult(t *testing.T) {
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
var calls atomic.Int64
|
||||
process := &ProcessMock{
|
||||
HealthyMock: func(_ *slog.Logger) bool {
|
||||
calls.Add(1)
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
|
||||
ps.firstStart.Store(true)
|
||||
|
||||
for range 5 {
|
||||
if !ps.Healthy() {
|
||||
t.Fatal("expected healthy")
|
||||
}
|
||||
}
|
||||
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("process.Healthy called %d times, want exactly 1 (cache should absorb the other 4)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessSupervisor_Healthy_DoesNotCacheNegativeResult verifies that
|
||||
// a probe failure is not cached: the next Healthy() call must re-issue
|
||||
// the underlying process check so a recovered process surfaces on the
|
||||
// very next probe.
|
||||
func TestProcessSupervisor_Healthy_DoesNotCacheNegativeResult(t *testing.T) {
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
var calls atomic.Int64
|
||||
var processHealthy atomic.Bool
|
||||
process := &ProcessMock{
|
||||
HealthyMock: func(_ *slog.Logger) bool {
|
||||
calls.Add(1)
|
||||
return processHealthy.Load()
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, "test", process, 5, 0, 1, 0).(*processSupervisor)
|
||||
ps.firstStart.Store(true)
|
||||
|
||||
processHealthy.Store(false)
|
||||
_ = ps.Healthy()
|
||||
_ = ps.Healthy()
|
||||
if got := calls.Load(); got != 2 {
|
||||
t.Fatalf("after two failing probes, process.Healthy called %d times, want 2 (negative results must not be cached)", got)
|
||||
}
|
||||
|
||||
processHealthy.Store(true)
|
||||
if !ps.Healthy() {
|
||||
t.Fatal("expected healthy on recovery")
|
||||
}
|
||||
if got := calls.Load(); got != 3 {
|
||||
t.Fatalf("after recovery, process.Healthy called %d times, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_Run(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
@@ -386,7 +489,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit, tc.maxQueueSize, 1, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, "test", process, tc.maxReqLimit, tc.maxQueueSize, 1, 0).(*processSupervisor)
|
||||
if tc.initiallyStarted {
|
||||
ps.firstStart.Store(true)
|
||||
}
|
||||
@@ -470,7 +573,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.DiscardHandler), "test", new(ProcessMock), 0, 0, 1, 0).(*processSupervisor)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
if tc.ctxDone {
|
||||
@@ -504,7 +607,7 @@ func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
|
||||
return true
|
||||
},
|
||||
}
|
||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, 0).(*processSupervisor)
|
||||
|
||||
// Simulating a lock.
|
||||
ps.semaphore <- struct{}{}
|
||||
@@ -565,7 +668,7 @@ func TestProcessSupervisor_QueueSizeCAS(t *testing.T) {
|
||||
|
||||
maxQueueSize := int64(50)
|
||||
// maxConcurrency=1 so all goroutines block on the semaphore, exercising queue logic.
|
||||
ps := NewProcessSupervisor(logger, process, 0, maxQueueSize, 1, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 0, maxQueueSize, 1, 0).(*processSupervisor)
|
||||
|
||||
// Simulating a lock so that all goroutines queue up.
|
||||
ps.semaphore <- struct{}{}
|
||||
@@ -619,7 +722,7 @@ func TestProcessSupervisor_QueueSizeIncludesActiveTasks(t *testing.T) {
|
||||
}
|
||||
|
||||
// maxQueueSize=1, maxConcurrency=1: only one request at a time.
|
||||
ps := NewProcessSupervisor(logger, process, 0, 1, 1, 0)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 0, 1, 1, 0)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -708,7 +811,7 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, 0).(*processSupervisor)
|
||||
ps.restartsCounter.Store(tc.initialRestartsCount)
|
||||
|
||||
for i := 0; i < tc.restartAttempts; i++ {
|
||||
@@ -741,7 +844,7 @@ func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
|
||||
}
|
||||
|
||||
maxConcurrency := int64(3)
|
||||
ps := NewProcessSupervisor(logger, process, 0, 0, maxConcurrency, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 0, 0, maxConcurrency, 0).(*processSupervisor)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -803,7 +906,7 @@ func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) {
|
||||
}
|
||||
|
||||
maxConcurrency := int64(3)
|
||||
ps := NewProcessSupervisor(logger, process, 3, 0, maxConcurrency, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 3, 0, maxConcurrency, 0).(*processSupervisor)
|
||||
ps.firstStart.Store(true)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -860,7 +963,7 @@ func TestProcessSupervisor_IdleShutdown(t *testing.T) {
|
||||
}
|
||||
|
||||
idleTimeout := 50 * time.Millisecond
|
||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, idleTimeout).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, idleTimeout).(*processSupervisor)
|
||||
|
||||
ctx := context.Background()
|
||||
err := ps.Run(ctx, logger, func() error {
|
||||
@@ -898,6 +1001,53 @@ func TestProcessSupervisor_IdleShutdown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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, "test", 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)
|
||||
|
||||
@@ -919,7 +1069,7 @@ func TestProcessSupervisor_IdleShutdownSkippedWhenActive(t *testing.T) {
|
||||
}
|
||||
|
||||
idleTimeout := 50 * time.Millisecond
|
||||
ps := NewProcessSupervisor(logger, process, 0, 0, 1, idleTimeout)
|
||||
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, idleTimeout)
|
||||
|
||||
ctx := context.Background()
|
||||
go func() {
|
||||
@@ -941,3 +1091,70 @@ func TestProcessSupervisor_IdleShutdownSkippedWhenActive(t *testing.T) {
|
||||
|
||||
close(taskDone)
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_ConversionsSinceRestart(t *testing.T) {
|
||||
process := &ProcessMock{
|
||||
StartMock: func(*slog.Logger) error { return nil },
|
||||
StopMock: func(*slog.Logger) error { return nil },
|
||||
HealthyMock: func(*slog.Logger) bool { return true },
|
||||
}
|
||||
s := NewProcessSupervisor(slog.New(slog.DiscardHandler), "test", process, 0, 0, 1, 0).(*processSupervisor)
|
||||
|
||||
if got := s.ConversionsSinceRestart(); got != 0 {
|
||||
t.Errorf("expected 0 conversions initially, got %d", got)
|
||||
}
|
||||
|
||||
s.reqCounter.Store(7)
|
||||
if got := s.ConversionsSinceRestart(); got != 7 {
|
||||
t.Errorf("expected 7 conversions, got %d", got)
|
||||
}
|
||||
|
||||
if err := s.restart(); err != nil {
|
||||
t.Fatalf("restart: %v", err)
|
||||
}
|
||||
if got := s.ConversionsSinceRestart(); got != 0 {
|
||||
t.Errorf("expected 0 conversions after restart, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_RunEmitsSubSpans(t *testing.T) {
|
||||
recorder := newTestSpanRecorder(t)
|
||||
|
||||
process := &ProcessMock{
|
||||
StartMock: func(*slog.Logger) error { return nil },
|
||||
StopMock: func(*slog.Logger) error { return nil },
|
||||
HealthyMock: func(*slog.Logger) bool { return true },
|
||||
}
|
||||
s := NewProcessSupervisor(slog.New(slog.DiscardHandler), "chromium", process, 0, 0, 1, 0)
|
||||
|
||||
err := s.Run(context.Background(), slog.New(slog.DiscardHandler), func() error { return nil })
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
|
||||
var queueWaits, processStarts int
|
||||
var startReason string
|
||||
for _, span := range recorder.Ended() {
|
||||
switch span.Name() {
|
||||
case "chromium.queue.wait":
|
||||
queueWaits++
|
||||
case "chromium.process.start":
|
||||
processStarts++
|
||||
for _, kv := range span.Attributes() {
|
||||
if string(kv.Key) == "gotenberg.process.start.reason" {
|
||||
startReason = kv.Value.AsString()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if queueWaits != 1 {
|
||||
t.Errorf("expected 1 chromium.queue.wait span, got %d", queueWaits)
|
||||
}
|
||||
if processStarts != 1 {
|
||||
t.Errorf("expected 1 chromium.process.start span (first start), got %d", processStarts)
|
||||
}
|
||||
if startReason != "first_start" {
|
||||
t.Errorf("expected process start reason first_start, got %q", startReason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,11 @@ const (
|
||||
DebugLoggingLevel = "debug"
|
||||
)
|
||||
|
||||
const (
|
||||
LowerLevelCase = "lower"
|
||||
UpperLevelCase = "upper"
|
||||
)
|
||||
|
||||
// TelemetryConfig gathers the configuration data for Gotenberg's telemetry.
|
||||
type TelemetryConfig struct {
|
||||
ServiceName string
|
||||
@@ -38,6 +43,7 @@ type TelemetryConfig struct {
|
||||
LogFieldsPrefix string
|
||||
LogStdFormat string
|
||||
LogStdEnableGcpFields bool
|
||||
LogStdLevelCase string
|
||||
}
|
||||
|
||||
func (cfg TelemetryConfig) slogLevel() slog.Level {
|
||||
@@ -85,6 +91,16 @@ func (cfg TelemetryConfig) Validate() error {
|
||||
)
|
||||
}
|
||||
|
||||
switch cfg.LogStdLevelCase {
|
||||
case LowerLevelCase, UpperLevelCase:
|
||||
break
|
||||
default:
|
||||
err = errors.Join(
|
||||
err,
|
||||
fmt.Errorf("standard log level case must be either %s or %s", LowerLevelCase, UpperLevelCase),
|
||||
)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -92,7 +108,7 @@ func (cfg TelemetryConfig) Validate() error {
|
||||
func StartTelemetry(cfg TelemetryConfig) (shutdown func(context.Context) error, err error) {
|
||||
var handlers []slog.Handler
|
||||
|
||||
stdHandler, err := log.NewStdHandler(cfg.slogLevel(), cfg.LogStdFormat, cfg.LogFieldsPrefix, cfg.LogStdEnableGcpFields)
|
||||
stdHandler, err := log.NewStdHandler(cfg.slogLevel(), cfg.LogStdFormat, cfg.LogFieldsPrefix, cfg.LogStdEnableGcpFields, cfg.LogStdLevelCase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get standard logger handler: %w", err)
|
||||
}
|
||||
|
||||
@@ -57,10 +57,13 @@ type Api struct {
|
||||
}
|
||||
|
||||
type downloadFromConfig struct {
|
||||
allowList []*regexp2.Regexp
|
||||
denyList []*regexp2.Regexp
|
||||
maxRetry int
|
||||
disable bool
|
||||
allowList []*regexp2.Regexp
|
||||
denyList []*regexp2.Regexp
|
||||
denyPrivateIPs bool
|
||||
denyPublicIPs bool
|
||||
enableEnvironmentProxy bool
|
||||
maxRetry int
|
||||
disable bool
|
||||
}
|
||||
|
||||
// Router is a module interface that adds routes to the [Api].
|
||||
@@ -197,6 +200,9 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
||||
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.Bool("api-download-from-enable-environment-proxy", false, "Route downloadFrom fetches through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials")
|
||||
fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature")
|
||||
fs.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")
|
||||
@@ -235,10 +241,13 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
||||
a.rootPath = flags.MustString("api-root-path")
|
||||
a.correlationIdHeader = flags.MustDeprecatedString("api-trace-header", "api-correlation-id-header")
|
||||
a.downloadFromCfg = downloadFromConfig{
|
||||
allowList: flags.MustRegexpSlice("api-download-from-allow-list"),
|
||||
denyList: flags.MustRegexpSlice("api-download-from-deny-list"),
|
||||
maxRetry: flags.MustInt("api-download-from-max-retry"),
|
||||
disable: flags.MustBool("api-disable-download-from"),
|
||||
allowList: flags.MustRegexpSlice("api-download-from-allow-list"),
|
||||
denyList: flags.MustRegexpSlice("api-download-from-deny-list"),
|
||||
denyPrivateIPs: flags.MustBool("api-download-from-deny-private-ips"),
|
||||
denyPublicIPs: flags.MustBool("api-download-from-deny-public-ips"),
|
||||
enableEnvironmentProxy: flags.MustBool("api-download-from-enable-environment-proxy"),
|
||||
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.disableRootRouteTelemetry = flags.MustBool("api-disable-root-route-telemetry")
|
||||
|
||||
@@ -111,7 +111,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
if bodyLimit != 0 && newTotal > bodyLimit {
|
||||
return WrapError(
|
||||
fmt.Errorf("body limit reached (> %d)", bodyLimit),
|
||||
NewSentinelHttpError(http.StatusRequestEntityTooLarge, http.StatusText(http.StatusRequestEntityTooLarge)),
|
||||
NewSentinelHttpError(http.StatusRequestEntityTooLarge, "The request body exceeds the configured size limit. Increase it with --api-body-limit, or send a smaller request."),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
@@ -216,6 +216,15 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
)
|
||||
}
|
||||
|
||||
// Each goroutine writes to its own results slot. The main
|
||||
// goroutine merges into ctx.files, ctx.diskToOriginal, and
|
||||
// ctx.filesByField after eg.Wait() to avoid concurrent map
|
||||
// writes.
|
||||
type downloadFromResult struct {
|
||||
filename, path, formField string
|
||||
}
|
||||
results := make([]downloadFromResult, len(dls))
|
||||
|
||||
eg, _ := errgroup.WithContext(ctx)
|
||||
for i, dl := range dls {
|
||||
eg.Go(func() error {
|
||||
@@ -232,7 +241,11 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
)
|
||||
}
|
||||
|
||||
err := gotenberg.FilterDeadline(downloadFromCfg.allowList, downloadFromCfg.denyList, dl.Url, deadline)
|
||||
ipOpts := []gotenberg.DecideOption{
|
||||
gotenberg.WithDenyPrivateIPs(downloadFromCfg.denyPrivateIPs),
|
||||
gotenberg.WithDenyPublicIPs(downloadFromCfg.denyPublicIPs),
|
||||
}
|
||||
err := gotenberg.FilterOutboundURL(ctx, dl.Url, downloadFromCfg.allowList, downloadFromCfg.denyList, deadline, ipOpts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("filter URL: %w", err)
|
||||
}
|
||||
@@ -268,9 +281,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
}
|
||||
|
||||
client := &retryablehttp.Client{
|
||||
HTTPClient: &http.Client{
|
||||
Timeout: time.Until(deadline),
|
||||
},
|
||||
HTTPClient: gotenberg.NewOutboundHttpClient(time.Until(deadline), downloadFromCfg.allowList, downloadFromCfg.denyList, downloadFromCfg.enableEnvironmentProxy, ipOpts...),
|
||||
RetryMax: downloadFromCfg.maxRetry,
|
||||
RetryWaitMin: time.Duration(1) * time.Second,
|
||||
RetryWaitMax: time.Until(deadline),
|
||||
@@ -346,10 +357,13 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
)
|
||||
}
|
||||
|
||||
// Avoid directory traversal and make sure filename characters are
|
||||
// normalized.
|
||||
// Strip path separators (including backslashes) and control
|
||||
// characters, then NFC-normalize. Defends against directory
|
||||
// traversal in the on-disk name and Windows-side Zip Slip
|
||||
// when the original filename is later embedded in an output
|
||||
// zip entry.
|
||||
// See: https://github.com/gotenberg/gotenberg/issues/662.
|
||||
filename = norm.NFC.String(filepath.Base(filename))
|
||||
filename = sanitizeFilename(filename)
|
||||
|
||||
// Use a UUID-based name on disk to avoid filesystem
|
||||
// NAME_MAX limits with long filenames.
|
||||
@@ -387,18 +401,16 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
dlSpan.SetStatus(codes.Ok, "")
|
||||
dlSpan.End()
|
||||
|
||||
ctx.files[filename] = path
|
||||
ctx.diskToOriginal[path] = filename
|
||||
|
||||
// Route the downloaded file to the appropriate field bucket.
|
||||
var formField string
|
||||
switch {
|
||||
case dl.Field == "embedded" || dl.Embedded:
|
||||
ctx.filesByField[EmbedsFormField] = append(ctx.filesByField[EmbedsFormField], path)
|
||||
formField = EmbedsFormField
|
||||
case dl.Field == "watermark":
|
||||
ctx.filesByField[WatermarkFormField] = append(ctx.filesByField[WatermarkFormField], path)
|
||||
formField = WatermarkFormField
|
||||
case dl.Field == "stamp":
|
||||
ctx.filesByField[StampFormField] = append(ctx.filesByField[StampFormField], path)
|
||||
formField = StampFormField
|
||||
}
|
||||
results[i] = downloadFromResult{filename: filename, path: path, formField: formField}
|
||||
|
||||
return nil
|
||||
})
|
||||
@@ -408,6 +420,14 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
if err != nil {
|
||||
return ctx, cancel, err
|
||||
}
|
||||
|
||||
for _, r := range results {
|
||||
ctx.files[r.filename] = r.path
|
||||
ctx.diskToOriginal[r.path] = r.filename
|
||||
if r.formField != "" {
|
||||
ctx.filesByField[r.formField] = append(ctx.filesByField[r.formField], r.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
copyToDisk := func(fh *multipart.FileHeader) error {
|
||||
@@ -426,10 +446,12 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
// This will ensure we do not exceed the body limit.
|
||||
reader := &trackingReader{R: in, AddReadBytes: addReadBytes}
|
||||
|
||||
// Avoid directory traversal and make sure filename characters are
|
||||
// normalized.
|
||||
// Strip path separators (including backslashes) and control
|
||||
// characters, then NFC-normalize. Defends against directory
|
||||
// traversal in the on-disk name and Windows-side Zip Slip when the
|
||||
// original filename is later embedded in an output zip entry.
|
||||
// See: https://github.com/gotenberg/gotenberg/issues/662.
|
||||
filename := norm.NFC.String(filepath.Base(fh.Filename))
|
||||
filename := sanitizeFilename(fh.Filename)
|
||||
|
||||
// Use a UUID-based name on disk to avoid filesystem
|
||||
// NAME_MAX limits with long filenames.
|
||||
@@ -467,12 +489,29 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
return ctx, cancel, fmt.Errorf("copy to disk: %w", err)
|
||||
}
|
||||
// Track files by field name
|
||||
filename := norm.NFC.String(filepath.Base(fh.Filename))
|
||||
filename := sanitizeFilename(fh.Filename)
|
||||
filePath := ctx.files[filename]
|
||||
ctx.filesByField[fieldName] = append(ctx.filesByField[fieldName], filePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Create symlinks from original filenames to UUID-based disk names
|
||||
// so that relative asset references (e.g., <img src="image.png">)
|
||||
// resolve correctly when Chromium navigates to a file:// URL.
|
||||
// Symlink creation is best-effort: it may fail for filenames that
|
||||
// exceed the filesystem NAME_MAX limit (the reason UUIDs were
|
||||
// introduced in the first place).
|
||||
for originalName, diskPath := range ctx.files {
|
||||
symlinkPath := fmt.Sprintf("%s/%s", ctx.dirPath, originalName)
|
||||
if symlinkPath == diskPath {
|
||||
continue
|
||||
}
|
||||
err = os.Symlink(filepath.Base(diskPath), symlinkPath)
|
||||
if err != nil {
|
||||
logger.DebugContext(context.Background(), fmt.Sprintf("skip symlink for '%s': %s", originalName, err))
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Log().DebugContext(ctx, fmt.Sprintf("form fields: %+v", ctx.values))
|
||||
ctx.Log().DebugContext(ctx, fmt.Sprintf("form files: %+v", ctx.files))
|
||||
ctx.Log().DebugContext(ctx, fmt.Sprintf("form files by field: %+v", ctx.filesByField))
|
||||
@@ -497,6 +536,11 @@ func (ctx *Context) FormData() *FormData {
|
||||
}
|
||||
}
|
||||
|
||||
// FileCount returns the number of files received in the request.
|
||||
func (ctx *Context) FileCount() int {
|
||||
return len(ctx.files)
|
||||
}
|
||||
|
||||
// OriginalFilename returns the original filename associated with a disk path.
|
||||
// If no mapping exists, it falls back to [filepath.Base].
|
||||
func (ctx *Context) OriginalFilename(diskPath string) string {
|
||||
@@ -639,3 +683,21 @@ func (ctx *Context) OutputFilename(outputPath string) string {
|
||||
|
||||
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))
|
||||
}
|
||||
|
||||
// sanitizeFilename strips path separators (including backslashes, which
|
||||
// [filepath.Base] ignores on Linux) and control characters from a
|
||||
// caller-supplied filename, then NFC-normalizes the result. This prevents a
|
||||
// Windows-side Zip Slip when an output zip is extracted by a permissive
|
||||
// extractor that interprets '\' as a path separator.
|
||||
func sanitizeFilename(name string) string {
|
||||
if i := strings.LastIndexAny(name, `/\`); i >= 0 {
|
||||
name = name[i+1:]
|
||||
}
|
||||
name = strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, name)
|
||||
return norm.NFC.String(name)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,13 @@ package api
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -69,3 +72,153 @@ func TestNewContext_Cancellation(t *testing.T) {
|
||||
t.Fatal("expected context to be cancelled after request context cancellation, but it timed out")
|
||||
}
|
||||
}
|
||||
|
||||
// Concurrent downloadFrom entries must not race on the shared maps
|
||||
// (ctx.files, ctx.diskToOriginal, ctx.filesByField). Run under -race
|
||||
// to catch the data race; without -race a sufficient number of entries
|
||||
// still surfaces "fatal error: concurrent map writes".
|
||||
func TestNewContext_DownloadFromConcurrentMapWrites(t *testing.T) {
|
||||
const downloads = 64
|
||||
|
||||
var ready sync.WaitGroup
|
||||
ready.Add(downloads)
|
||||
release := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ready.Done()
|
||||
go func() {
|
||||
ready.Wait()
|
||||
releaseOnce.Do(func() { close(release) })
|
||||
}()
|
||||
<-release
|
||||
|
||||
filename := fmt.Sprintf("download-%s.txt", r.URL.Query().Get("i"))
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
_, _ = w.Write([]byte("downloaded"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
dls := make([]downloadFrom, downloads)
|
||||
for i := range dls {
|
||||
dls[i] = downloadFrom{
|
||||
Url: fmt.Sprintf("%s/file?i=%d", server.URL, i),
|
||||
Field: "embedded",
|
||||
}
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(dls)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal downloadFrom payload: %v", err)
|
||||
}
|
||||
|
||||
body := new(bytes.Buffer)
|
||||
writer := multipart.NewWriter(body)
|
||||
err = writer.WriteField("downloadFrom", string(payload))
|
||||
if err != nil {
|
||||
t.Fatalf("write downloadFrom field: %v", err)
|
||||
}
|
||||
err = writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/forms/libreoffice/convert", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
echoCtx := echo.New().NewContext(req, httptest.NewRecorder())
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
||||
downloadFromCfg := downloadFromConfig{
|
||||
maxRetry: 0,
|
||||
}
|
||||
|
||||
ctx, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromCfg)
|
||||
if err != nil {
|
||||
t.Fatalf("newContext returned error: %v", err)
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
if got := len(ctx.files); got != downloads {
|
||||
t.Fatalf("downloaded files = %d, want %d", got, downloads)
|
||||
}
|
||||
if got := len(ctx.diskToOriginal); got != downloads {
|
||||
t.Fatalf("diskToOriginal entries = %d, want %d", got, downloads)
|
||||
}
|
||||
if got := len(ctx.filesByField[EmbedsFormField]); got != downloads {
|
||||
t.Fatalf("filesByField[%q] entries = %d, want %d", EmbedsFormField, got, downloads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFilename(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
input string
|
||||
expect string
|
||||
}{
|
||||
{
|
||||
scenario: "plain filename is unchanged",
|
||||
input: "report.pdf",
|
||||
expect: "report.pdf",
|
||||
},
|
||||
{
|
||||
scenario: "POSIX traversal is stripped",
|
||||
input: "../../etc/passwd",
|
||||
expect: "passwd",
|
||||
},
|
||||
{
|
||||
scenario: "Windows traversal with backslashes is stripped",
|
||||
input: `..\..\..\..\Windows\System32\evil.pdf`,
|
||||
expect: "evil.pdf",
|
||||
},
|
||||
{
|
||||
scenario: "mixed separators take the last segment",
|
||||
input: `foo/bar\baz.pdf`,
|
||||
expect: "baz.pdf",
|
||||
},
|
||||
{
|
||||
scenario: "control characters are dropped",
|
||||
input: "evil\x00\x07\x1f\x7f.pdf",
|
||||
expect: "evil.pdf",
|
||||
},
|
||||
{
|
||||
scenario: "NFC normalization collapses decomposed sequences",
|
||||
// "e" + combining acute accent -> precomposed "é".
|
||||
input: "café.pdf",
|
||||
expect: "café.pdf",
|
||||
},
|
||||
{
|
||||
scenario: "trailing backslash yields empty name",
|
||||
input: `foo\`,
|
||||
expect: "",
|
||||
},
|
||||
{
|
||||
scenario: "empty input yields empty name",
|
||||
input: "",
|
||||
expect: "",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
got := sanitizeFilename(tc.input)
|
||||
if got != tc.expect {
|
||||
t.Errorf("sanitizeFilename(%q) = %q, want %q", tc.input, got, tc.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_FileCount(t *testing.T) {
|
||||
ctx := &Context{}
|
||||
if got := ctx.FileCount(); got != 0 {
|
||||
t.Errorf("expected 0 files, got %d", got)
|
||||
}
|
||||
|
||||
ctx.files = map[string]string{
|
||||
"index.html": "/work/index.html",
|
||||
"header.html": "/work/header.html",
|
||||
"styles.css": "/work/styles.css",
|
||||
}
|
||||
if got := ctx.FileCount(); got != 3 {
|
||||
t.Errorf("expected 3 files, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// Package api provides a module, which is an HTTP server. Other modules may
|
||||
// add multipart/form-data routes, middlewares, and health checks.
|
||||
// Package api implements the HTTP server module. Other modules register
|
||||
// multipart/form data routes, middlewares, and health checks through it.
|
||||
package api
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
@@ -25,6 +26,10 @@ const (
|
||||
|
||||
// StampFormField represents the form field name for the stamp file.
|
||||
StampFormField string = "stamp"
|
||||
|
||||
// FacturXXmlFormField represents the form field name for the Factur-X CII
|
||||
// invoice XML file.
|
||||
FacturXXmlFormField string = "facturxXml"
|
||||
)
|
||||
|
||||
// FormData is a helper for validating and hydrating values from a
|
||||
@@ -391,6 +396,38 @@ func (form *FormData) Embeds(target *[]string) *FormData {
|
||||
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.
|
||||
@@ -442,13 +479,28 @@ func (form *FormData) Stamp(target *string) *FormData {
|
||||
return form
|
||||
}
|
||||
|
||||
// FacturXXml binds the absolute path of the uploaded Factur-X CII invoice
|
||||
// XML. Only a file uploaded with the "facturxXml" field name is included.
|
||||
func (form *FormData) FacturXXml(target *string) *FormData {
|
||||
if form.errors != nil {
|
||||
return form
|
||||
}
|
||||
|
||||
if paths, ok := form.filesByField[FacturXXmlFormField]; ok && len(paths) > 0 {
|
||||
*target = paths[0]
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// paths bind the absolute paths of form data files, according to a list of
|
||||
// file extensions, to a string slice variable.
|
||||
// embeds, watermark, and stamp files are excluded.
|
||||
// embeds, watermark, stamp, and facturxXml files are excluded.
|
||||
func (form *FormData) paths(extensions []string, target *[]string) *FormData {
|
||||
embeds, ok := form.filesByField[EmbedsFormField]
|
||||
watermarks, wmOk := form.filesByField[WatermarkFormField]
|
||||
stamps, stOk := form.filesByField[StampFormField]
|
||||
facturxXmls, fxOk := form.filesByField[FacturXXmlFormField]
|
||||
|
||||
// Collect (originalFilename, diskPath) pairs so that we can sort by
|
||||
// original filename rather than by UUID-based disk name.
|
||||
@@ -472,6 +524,10 @@ func (form *FormData) paths(extensions []string, target *[]string) *FormData {
|
||||
continue
|
||||
}
|
||||
|
||||
if fxOk && slices.Contains(facturxXmls, path) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ext := range extensions {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/228.
|
||||
if strings.ToLower(filepath.Ext(filename)) == ext {
|
||||
|
||||
@@ -1783,3 +1783,57 @@ func TestFormData_Embeds(t *testing.T) {
|
||||
t.Errorf("expected %v but got %v", expected, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormData_FacturXXml(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
form *FormData
|
||||
expect string
|
||||
}{
|
||||
{
|
||||
scenario: "no facturxXml file",
|
||||
form: &FormData{},
|
||||
expect: "",
|
||||
},
|
||||
{
|
||||
scenario: "facturxXml file present",
|
||||
form: &FormData{
|
||||
filesByField: map[string][]string{
|
||||
FacturXXmlFormField: {"/tmp/abc/12345.xml"},
|
||||
},
|
||||
},
|
||||
expect: "/tmp/abc/12345.xml",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
var actual string
|
||||
|
||||
tc.form.FacturXXml(&actual)
|
||||
|
||||
if actual != tc.expect {
|
||||
t.Errorf("expected %q but got %q", tc.expect, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormData_paths_excludesFacturXXml verifies that an uploaded facturxXml is
|
||||
// never picked up as an input document by paths().
|
||||
func TestFormData_paths_excludesFacturXXml(t *testing.T) {
|
||||
form := &FormData{
|
||||
files: map[string]string{
|
||||
"document.xml": "/tmp/abc/document.xml",
|
||||
"factur-x.xml": "/tmp/abc/invoice.xml",
|
||||
},
|
||||
filesByField: map[string][]string{
|
||||
FacturXXmlFormField: {"/tmp/abc/invoice.xml"},
|
||||
},
|
||||
}
|
||||
|
||||
var paths []string
|
||||
form.paths([]string{".xml"}, &paths)
|
||||
|
||||
if len(paths) != 1 || paths[0] != "/tmp/abc/document.xml" {
|
||||
t.Errorf("expected only the non-Factur-X .xml document, got %+v", paths)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func ParseError(err error) (int, string) {
|
||||
}
|
||||
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return http.StatusServiceUnavailable, http.StatusText(http.StatusServiceUnavailable)
|
||||
return http.StatusServiceUnavailable, "The request exceeded the time limit. Increase it with --api-timeout, or reduce the workload."
|
||||
}
|
||||
|
||||
if errors.Is(err, gotenberg.ErrFiltered) {
|
||||
@@ -51,27 +51,27 @@ func ParseError(err error) (int, string) {
|
||||
}
|
||||
|
||||
if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
|
||||
return http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests)
|
||||
return http.StatusTooManyRequests, "The request queue is full. Retry shortly, or raise the limit with --chromium-max-queue-size or --libreoffice-max-queue-size."
|
||||
}
|
||||
|
||||
if errors.Is(err, gotenberg.ErrPdfSplitModeNotSupported) {
|
||||
return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues"
|
||||
return http.StatusBadRequest, "The requested split mode is not supported, or no PDF engine could process it. Valid modes: 'intervals', 'pages'."
|
||||
}
|
||||
|
||||
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
|
||||
return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues"
|
||||
return http.StatusBadRequest, "The requested PDF format is not supported, or no PDF engine could apply it. Valid formats include PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA."
|
||||
}
|
||||
|
||||
if errors.Is(err, gotenberg.ErrPdfEngineMetadataValueNotSupported) {
|
||||
return http.StatusBadRequest, "At least one PDF engine cannot process the requested metadata, while others may have failed to convert due to different issues"
|
||||
return http.StatusBadRequest, "The requested metadata could not be written; ensure values are valid and free of control characters."
|
||||
}
|
||||
|
||||
if errors.Is(err, gotenberg.ErrPdfStampSourceNotSupported) {
|
||||
return http.StatusBadRequest, "At least one PDF engine cannot process the requested stamp source type, while others may have failed due to different issues"
|
||||
return http.StatusBadRequest, "The requested stamp source is not supported, or no PDF engine could process it. Valid sources: 'text', 'image', 'pdf'."
|
||||
}
|
||||
|
||||
if errors.Is(err, gotenberg.ErrPdfRotateAngleNotSupported) {
|
||||
return http.StatusBadRequest, "At least one PDF engine cannot process the requested rotation angle, while others may have failed due to different issues"
|
||||
return http.StatusBadRequest, "The requested rotation angle is not supported. Valid angles: 90, 180, 270."
|
||||
}
|
||||
|
||||
if invalidArgsError, ok := errors.AsType[*gotenberg.PdfEngineInvalidArgsError](err); ok {
|
||||
@@ -337,7 +337,10 @@ func basicAuthMiddleware(username, password string) echo.MiddlewareFunc {
|
||||
func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
logger := c.Get("logger").(*slog.Logger)
|
||||
logger, _ := c.Get("logger").(*slog.Logger)
|
||||
if logger == nil {
|
||||
return errors.New("no logger in context (possible pool reuse)")
|
||||
}
|
||||
|
||||
// We create a context with a timeout so that underlying processes are
|
||||
// able to stop early and correctly handle a timeout scenario.
|
||||
@@ -395,7 +398,14 @@ func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimi
|
||||
func hardTimeoutMiddleware(hardTimeout time.Duration) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
logger := c.Get("logger").(*slog.Logger)
|
||||
// Guard the type assertion so a pooled [echo.Context] whose
|
||||
// store has been recycled under us does not crash the process.
|
||||
// See the webhook async handler for the race this protects
|
||||
// against.
|
||||
logger, _ := c.Get("logger").(*slog.Logger)
|
||||
if logger == nil {
|
||||
return errors.New("no logger in context (possible pool reuse)")
|
||||
}
|
||||
|
||||
// Define a hard timeout if the route handler fails to timeout as
|
||||
// expected.
|
||||
|
||||
39
pkg/modules/api/middlewares_test.go
Normal file
39
pkg/modules/api/middlewares_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func TestHardTimeoutMiddleware_MissingLoggerReturnsErrorInsteadOfPanicking(t *testing.T) {
|
||||
mw := hardTimeoutMiddleware(100 * time.Millisecond)
|
||||
handler := mw(func(c echo.Context) error { return nil })
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
// c has no "logger" key, mimicking a pooled context whose store was
|
||||
// recycled under a concurrently running webhook goroutine. The
|
||||
// middleware must surface an error instead of panicking on the
|
||||
// unchecked type assertion the pre-fix code relied on.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("hardTimeoutMiddleware panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
err := handler(c)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for missing logger, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "logger") {
|
||||
t.Fatalf("error = %q, want a message mentioning logger", err)
|
||||
}
|
||||
}
|
||||
87
pkg/modules/chromium/attrs_test.go
Normal file
87
pkg/modules/chromium/attrs_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||
)
|
||||
|
||||
func TestPrintToPdfAttrs(t *testing.T) {
|
||||
options := DefaultPdfOptions()
|
||||
options.Landscape = true
|
||||
options.PageRanges = "1-5"
|
||||
options.HeaderTemplate = "<div>secret header</div>"
|
||||
// FooterTemplate left at default, so has_footer must be false.
|
||||
|
||||
got := map[string]attribute.Value{}
|
||||
for _, kv := range printToPdfAttrs(options) {
|
||||
got[string(kv.Key)] = kv.Value
|
||||
if s := kv.Value.AsString(); strings.Contains(s, "secret") || s == "1-5" {
|
||||
t.Errorf("attribute %s leaked a raw value: %q", kv.Key, s)
|
||||
}
|
||||
}
|
||||
|
||||
if !got["gotenberg.chromium.print.landscape"].AsBool() {
|
||||
t.Error("expected landscape=true")
|
||||
}
|
||||
if !got["gotenberg.chromium.print.has_page_ranges"].AsBool() {
|
||||
t.Error("expected has_page_ranges=true")
|
||||
}
|
||||
if !got["gotenberg.chromium.print.has_header"].AsBool() {
|
||||
t.Error("expected has_header=true")
|
||||
}
|
||||
if got["gotenberg.chromium.print.has_footer"].AsBool() {
|
||||
t.Error("expected has_footer=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversionInputAttrs(t *testing.T) {
|
||||
tmp := filepath.Join(t.TempDir(), "index.html")
|
||||
content := []byte("<html></html>")
|
||||
if err := os.WriteFile(tmp, content, 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
t.Run("file URL with non-api context", func(t *testing.T) {
|
||||
got := map[string]int64{}
|
||||
for _, kv := range conversionInputAttrs(context.Background(), "file://"+tmp) {
|
||||
got[string(kv.Key)] = kv.Value.AsInt64()
|
||||
}
|
||||
|
||||
if _, ok := got["gotenberg.conversion.input.files.count"]; ok {
|
||||
t.Error("did not expect files.count for a non-api context")
|
||||
}
|
||||
if got["gotenberg.conversion.input.html.bytes"] != int64(len(content)) {
|
||||
t.Errorf("expected html.bytes=%d, got %d", len(content), got["gotenberg.conversion.input.html.bytes"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("remote URL yields no html.bytes", func(t *testing.T) {
|
||||
for _, kv := range conversionInputAttrs(context.Background(), "https://example.com") {
|
||||
if string(kv.Key) == "gotenberg.conversion.input.html.bytes" {
|
||||
t.Error("did not expect html.bytes for a remote URL")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("api context yields files.count", func(t *testing.T) {
|
||||
var found bool
|
||||
for _, kv := range conversionInputAttrs(&api.Context{}, "https://example.com") {
|
||||
if string(kv.Key) == "gotenberg.conversion.input.files.count" {
|
||||
found = true
|
||||
if kv.Value.AsInt64() != 0 {
|
||||
t.Errorf("expected files.count=0, got %d", kv.Value.AsInt64())
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected files.count for an api context")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -25,8 +25,8 @@ import (
|
||||
|
||||
type browser interface {
|
||||
gotenberg.Process
|
||||
pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error
|
||||
screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error
|
||||
pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions, aggregate *networkAggregate) error
|
||||
screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions, aggregate *networkAggregate) error
|
||||
}
|
||||
|
||||
type browserArguments struct {
|
||||
@@ -38,12 +38,15 @@ type browserArguments struct {
|
||||
allowFileAccessFromFiles bool
|
||||
hostResolverRules string
|
||||
proxyServer string
|
||||
enableEnvironmentProxy bool
|
||||
wsUrlReadTimeout time.Duration
|
||||
hyphenDataDirPath string
|
||||
|
||||
// Tasks specific.
|
||||
allowList []*regexp2.Regexp
|
||||
denyList []*regexp2.Regexp
|
||||
denyPrivateIPs bool
|
||||
denyPublicIPs bool
|
||||
clearCache bool
|
||||
clearCookies bool
|
||||
disableJavaScript bool
|
||||
@@ -56,16 +59,26 @@ type chromiumBrowser struct {
|
||||
userProfileDirPath string
|
||||
ctxMu sync.RWMutex
|
||||
isStarted atomic.Bool
|
||||
// startMu serializes Start calls. The supervisor's runWithDeadline
|
||||
// abandons a Start goroutine when the request deadline expires while
|
||||
// Chromium's startup handshake is still hanging; the abandoned goroutine
|
||||
// keeps running, holding the resources it acquired (the pinning proxy).
|
||||
// Serializing here prevents a second, overlapping Start from colliding
|
||||
// with the in-flight one on the shared pinning proxy.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1599.
|
||||
startMu sync.Mutex
|
||||
|
||||
arguments browserArguments
|
||||
fs *gotenberg.FileSystem
|
||||
arguments browserArguments
|
||||
fs *gotenberg.FileSystem
|
||||
pinningProxy *pinningProxy
|
||||
}
|
||||
|
||||
func newChromiumBrowser(arguments browserArguments) browser {
|
||||
b := &chromiumBrowser{
|
||||
initialCtx: context.Background(),
|
||||
arguments: arguments,
|
||||
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
|
||||
initialCtx: context.Background(),
|
||||
arguments: arguments,
|
||||
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
|
||||
pinningProxy: newPinningProxy(arguments.allowList, arguments.denyList, arguments.denyPrivateIPs, arguments.denyPublicIPs, arguments.enableEnvironmentProxy),
|
||||
}
|
||||
b.isStarted.Store(false)
|
||||
|
||||
@@ -73,6 +86,19 @@ func newChromiumBrowser(arguments browserArguments) browser {
|
||||
}
|
||||
|
||||
func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
||||
// Refuse to run while a previous Start is still in flight. That previous
|
||||
// Start may be a goroutine the supervisor abandoned after the request
|
||||
// deadline expired while the Chromium startup handshake was hanging; it
|
||||
// still holds the pinning proxy it started. An abandoned goroutine keeps
|
||||
// holding startMu until it unwinds (bounded by --chromium-start-timeout),
|
||||
// so no overlapping Start can collide with it on the shared pinning proxy
|
||||
// and latch Chromium into a permanent "pinning proxy already started"
|
||||
// state. See https://github.com/gotenberg/gotenberg/issues/1599.
|
||||
if !b.startMu.TryLock() {
|
||||
return errors.New("browser start already in progress")
|
||||
}
|
||||
defer b.startMu.Unlock()
|
||||
|
||||
if b.isStarted.Load() {
|
||||
return errors.New("browser is already started")
|
||||
}
|
||||
@@ -136,6 +162,25 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
||||
opts = append(opts, chromedp.ProxyServer(b.arguments.proxyServer))
|
||||
}
|
||||
|
||||
// Default: route Chromium through the internal pinning proxy so that
|
||||
// Chromium never performs its own DNS lookup for the navigation URL
|
||||
// or any sub-resource. The proxy resolves and validates each URL
|
||||
// once per request and dials the pinned IP, closing the DNS
|
||||
// rebinding window between Gotenberg's validation and Chromium's
|
||||
// connect.
|
||||
//
|
||||
// Skip when the operator has configured their own egress proxy or
|
||||
// custom host-resolver mappings: those deployments take
|
||||
// responsibility for outbound safety themselves and routing through
|
||||
// an internal proxy would override their configuration.
|
||||
if b.arguments.proxyServer == "" && b.arguments.hostResolverRules == "" {
|
||||
err = b.pinningProxy.Start(logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("start pinning proxy: %w", err)
|
||||
}
|
||||
opts = append(opts, chromedp.ProxyServer(b.pinningProxy.URL()))
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/524.
|
||||
opts = append(opts, chromedp.WSURLReadTimeout(b.arguments.wsUrlReadTimeout))
|
||||
|
||||
@@ -146,6 +191,15 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
||||
if err != nil {
|
||||
cancel()
|
||||
allocatorCancel()
|
||||
// The pinning proxy started before chromedp; tear it down so a
|
||||
// supervisor retry can re-bind. Stop is a no-op when the proxy
|
||||
// was never started (operator-configured --chromium-proxy-server
|
||||
// or --chromium-host-resolver-rules).
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1559.
|
||||
stopErr := b.pinningProxy.Stop(logger)
|
||||
if stopErr != nil {
|
||||
logger.ErrorContext(context.Background(), fmt.Sprintf("stop pinning proxy after failed start: %s", stopErr))
|
||||
}
|
||||
return fmt.Errorf("run exec allocator: %w", err)
|
||||
}
|
||||
|
||||
@@ -236,6 +290,15 @@ func (b *chromiumBrowser) Stop(logger *slog.Logger) error {
|
||||
b.userProfileDirPath = ""
|
||||
b.isStarted.Store(false)
|
||||
|
||||
// Stop the pinning proxy after Chromium shutdown so that any
|
||||
// in-flight requests Chromium issues during teardown complete. The
|
||||
// Stop call is a no-op when the proxy was not started (operator
|
||||
// configured --chromium-proxy-server or --chromium-host-resolver-rules).
|
||||
err := b.pinningProxy.Stop(logger)
|
||||
if err != nil {
|
||||
logger.ErrorContext(context.Background(), fmt.Sprintf("stop pinning proxy: %s", err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -273,10 +336,10 @@ func (b *chromiumBrowser) Healthy(logger *slog.Logger) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
|
||||
func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions, aggregate *networkAggregate) error {
|
||||
// Note: no error wrapping because it leaks on errors we want to display to
|
||||
// the end user.
|
||||
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
|
||||
return b.do(ctx, logger, url, options.Options, aggregate, chromedp.Tasks{
|
||||
network.Enable(),
|
||||
fetch.Enable(),
|
||||
runtime.Enable(),
|
||||
@@ -293,16 +356,16 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, out
|
||||
waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector),
|
||||
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
|
||||
// PDF specific.
|
||||
printToPdfActionFunc(logger, outputPath, options),
|
||||
printToPdfActionFunc(ctx, logger, outputPath, options),
|
||||
// Teardown.
|
||||
page.Close(),
|
||||
})
|
||||
}
|
||||
|
||||
func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions, aggregate *networkAggregate) error {
|
||||
// Note: no error wrapping because it leaks on errors we want to display to
|
||||
// the end user.
|
||||
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
|
||||
return b.do(ctx, logger, url, options.Options, aggregate, chromedp.Tasks{
|
||||
network.Enable(),
|
||||
fetch.Enable(),
|
||||
runtime.Enable(),
|
||||
@@ -319,14 +382,14 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, u
|
||||
waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector),
|
||||
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
|
||||
// Screenshot specific.
|
||||
setDeviceMetricsOverride(logger, options.Width, options.Height),
|
||||
setDeviceMetricsOverride(logger, options.Width, options.Height, options.DeviceScaleFactor),
|
||||
captureScreenshotActionFunc(logger, outputPath, options),
|
||||
// Teardown.
|
||||
page.Close(),
|
||||
})
|
||||
}
|
||||
|
||||
func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url string, options Options, tasks chromedp.Tasks) error {
|
||||
func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url string, options Options, aggregate *networkAggregate, tasks chromedp.Tasks) error {
|
||||
if !b.isStarted.Load() {
|
||||
return errors.New("browser not started, cannot handle tasks")
|
||||
}
|
||||
@@ -336,8 +399,12 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
||||
return errors.New("context has no deadline")
|
||||
}
|
||||
|
||||
// We validate the "main" URL against our allowed / deny lists.
|
||||
err := gotenberg.FilterDeadline(b.arguments.allowList, b.arguments.denyList, url, deadline)
|
||||
// We validate the "main" URL against our allowed / deny lists, and
|
||||
// against the IP-based outbound URL guard. See [gotenberg.FilterOutboundURL].
|
||||
err := gotenberg.FilterOutboundURL(ctx, url, b.arguments.allowList, b.arguments.denyList, deadline,
|
||||
gotenberg.WithDenyPrivateIPs(b.arguments.denyPrivateIPs),
|
||||
gotenberg.WithDenyPublicIPs(b.arguments.denyPublicIPs),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("filter URL: %w", err)
|
||||
}
|
||||
@@ -351,6 +418,9 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
||||
taskCtx, taskCancel := chromedp.NewContext(timeoutCtx)
|
||||
defer taskCancel()
|
||||
|
||||
// Accumulate per-conversion network activity for telemetry.
|
||||
listenForNetworkActivity(taskCtx, aggregate)
|
||||
|
||||
// We validate all other requests against our allowed / deny lists.
|
||||
// If a request does not pass the validation, we make it fail. It also set
|
||||
// the extra HTTP headers, if any.
|
||||
@@ -358,6 +428,8 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
||||
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,
|
||||
})
|
||||
|
||||
39
pkg/modules/chromium/browser_test.go
Normal file
39
pkg/modules/chromium/browser_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestChromiumBrowser_Start_rejectsOverlappingStart guards against the latch
|
||||
// reported in https://github.com/gotenberg/gotenberg/issues/1599. When the
|
||||
// supervisor abandons a Start goroutine on request-deadline expiry, that
|
||||
// goroutine keeps running and holds startMu (and the pinning proxy it started)
|
||||
// until it unwinds. A second Start must be refused rather than proceeding to
|
||||
// start the pinning proxy a second time.
|
||||
func TestChromiumBrowser_Start_rejectsOverlappingStart(t *testing.T) {
|
||||
b := &chromiumBrowser{initialCtx: context.Background()}
|
||||
|
||||
// Simulate a Start still in flight.
|
||||
b.startMu.Lock()
|
||||
defer b.startMu.Unlock()
|
||||
|
||||
err := b.Start(slog.New(slog.DiscardHandler))
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when a start is already in progress, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "already in progress") {
|
||||
t.Fatalf("expected an 'already in progress' error, got %q", err)
|
||||
}
|
||||
|
||||
// The guard must return before touching any startup resource, so no user
|
||||
// profile directory is created and the browser stays not started.
|
||||
if b.userProfileDirPath != "" {
|
||||
t.Fatalf("expected no user profile directory to be created, got %q", b.userProfileDirPath)
|
||||
}
|
||||
if b.isStarted.Load() {
|
||||
t.Fatal("expected the browser to stay not started")
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -101,12 +102,17 @@ type Chromium struct {
|
||||
supervisor gotenberg.ProcessSupervisor
|
||||
engine gotenberg.PdfEngine
|
||||
|
||||
version string
|
||||
versionOnce sync.Once
|
||||
|
||||
reqsCounter metric.Int64Counter
|
||||
errsCounter metric.Int64Counter
|
||||
conversionDurationCounter metric.Float64Histogram
|
||||
queueWaitDurationCounter metric.Float64Histogram
|
||||
pdfOutputSizeCounter metric.Int64Histogram
|
||||
imageOutputSizeCounter metric.Int64Histogram
|
||||
networkRequestsCounter metric.Int64Counter
|
||||
networkBytesCounter metric.Int64Histogram
|
||||
}
|
||||
|
||||
// Options are the common options for all conversions.
|
||||
@@ -189,10 +195,13 @@ type Options struct {
|
||||
// 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. Set internally by route handlers, not
|
||||
// via form data.
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -291,7 +300,8 @@ type PdfOptions struct {
|
||||
PreferCssPageSize bool
|
||||
|
||||
// GenerateDocumentOutline defines whether the document outline should be
|
||||
// embedded into the PDF.
|
||||
// embedded into the PDF. Chromium derives the outline from the tagged-PDF
|
||||
// structure tree, so enabling this implies GenerateTaggedPdf.
|
||||
GenerateDocumentOutline bool
|
||||
|
||||
// GenerateTaggedPdf defines whether to generate tagged (accessible)
|
||||
@@ -347,18 +357,23 @@ type ScreenshotOptions struct {
|
||||
// OptimizeForSpeed defines whether to optimize image encoding for speed,
|
||||
// not for resulting size.
|
||||
OptimizeForSpeed bool
|
||||
|
||||
// DeviceScaleFactor is the ratio of the resolution in physical pixels to
|
||||
// the resolution in CSS pixels for the current display device.
|
||||
DeviceScaleFactor float64
|
||||
}
|
||||
|
||||
// DefaultScreenshotOptions returns the default values for ScreenshotOptions.
|
||||
func DefaultScreenshotOptions() ScreenshotOptions {
|
||||
return ScreenshotOptions{
|
||||
Options: DefaultOptions(),
|
||||
Width: 800,
|
||||
Height: 600,
|
||||
Clip: false,
|
||||
Format: "png",
|
||||
Quality: 100,
|
||||
OptimizeForSpeed: false,
|
||||
Options: DefaultOptions(),
|
||||
Width: 800,
|
||||
Height: 600,
|
||||
Clip: false,
|
||||
Format: "png",
|
||||
Quality: 100,
|
||||
OptimizeForSpeed: false,
|
||||
DeviceScaleFactor: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,8 +460,11 @@ 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.Bool("chromium-enable-environment-proxy", false, "Route Chromium's outbound requests through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials. Use this instead of --chromium-proxy-server for authenticated proxies, and leave --chromium-proxy-server and --chromium-host-resolver-rules unset")
|
||||
fs.StringSlice("chromium-allow-list", []string{}, "Set the allowed URLs for Chromium using regular expressions - supports multiple values")
|
||||
fs.StringSlice("chromium-deny-list", []string{`^file:(?!//\/tmp/).*`}, "Set the denied URLs for Chromium using regular expressions - supports multiple values")
|
||||
fs.Bool("chromium-deny-private-ips", false, "Reject URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted form input to mitigate SSRF against internal services")
|
||||
fs.Bool("chromium-deny-public-ips", false, "Reject URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent outbound traffic from leaving a private network")
|
||||
fs.Bool("chromium-clear-cache", false, "Clear Chromium cache between each conversion")
|
||||
fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion")
|
||||
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
|
||||
@@ -474,12 +492,12 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
||||
|
||||
binPath, ok := os.LookupEnv("CHROMIUM_BIN_PATH")
|
||||
if !ok {
|
||||
return errors.New("CHROMIUM_BIN_PATH environment variable is not set")
|
||||
return errors.New("CHROMIUM_BIN_PATH environment variable is not set; set it to the absolute path of the Chromium or Chrome binary")
|
||||
}
|
||||
|
||||
hyphenDataDirPath, ok := os.LookupEnv("CHROMIUM_HYPHEN_DATA_DIR_PATH")
|
||||
if !ok {
|
||||
return errors.New("CHROMIUM_HYPHEN_DATA_DIR_PATH environment variable is not set")
|
||||
return errors.New("CHROMIUM_HYPHEN_DATA_DIR_PATH environment variable is not set; set it to the absolute path of the Chromium hyphenation data directory (it ships in the Gotenberg image)")
|
||||
}
|
||||
|
||||
mod.args = browserArguments{
|
||||
@@ -490,11 +508,14 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
||||
allowFileAccessFromFiles: flags.MustBool("chromium-allow-file-access-from-files"),
|
||||
hostResolverRules: flags.MustString("chromium-host-resolver-rules"),
|
||||
proxyServer: flags.MustString("chromium-proxy-server"),
|
||||
enableEnvironmentProxy: flags.MustBool("chromium-enable-environment-proxy"),
|
||||
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"),
|
||||
clearCache: flags.MustBool("chromium-clear-cache"),
|
||||
clearCookies: flags.MustBool("chromium-clear-cookies"),
|
||||
disableJavaScript: flags.MustBool("chromium-disable-javascript"),
|
||||
@@ -505,7 +526,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, "chromium", mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"), mod.maxConcurrency, flags.MustDuration("chromium-idle-shutdown-timeout"))
|
||||
|
||||
// PDF Engine.
|
||||
provider, err := ctx.Module(new(gotenberg.PdfEngineProvider))
|
||||
@@ -621,6 +642,24 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
||||
return fmt.Errorf("create chromium.image.output.size histogram: %w", err)
|
||||
}
|
||||
|
||||
mod.networkRequestsCounter, err = meter.Int64Counter(
|
||||
"chromium.network.requests.total",
|
||||
metric.WithDescription("Total number of network requests made during Chromium conversions"),
|
||||
metric.WithUnit("{request}"),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create chromium.network.requests.total counter: %w", err)
|
||||
}
|
||||
|
||||
mod.networkBytesCounter, err = meter.Int64Histogram(
|
||||
"chromium.network.bytes",
|
||||
metric.WithDescription("Bytes fetched over the network during a Chromium conversion"),
|
||||
metric.WithUnit("By"),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create chromium.network.bytes histogram: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -632,12 +671,12 @@ func (mod *Chromium) Validate() error {
|
||||
|
||||
_, err := os.Stat(mod.args.binPath)
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("chromium binary path does not exist: %w", err)
|
||||
return fmt.Errorf("Chromium binary does not exist at %q; check the CHROMIUM_BIN_PATH environment variable: %w", mod.args.binPath, err)
|
||||
}
|
||||
|
||||
_, err = os.Stat(mod.args.hyphenDataDirPath)
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("chromium hyphen-data directory path does not exist: %w", err)
|
||||
return fmt.Errorf("Chromium hyphenation data directory does not exist at %q; check the CHROMIUM_HYPHEN_DATA_DIR_PATH environment variable (it ships in the Gotenberg image): %w", mod.args.hyphenDataDirPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -685,19 +724,46 @@ func (mod *Chromium) Stop(ctx context.Context) error {
|
||||
|
||||
// Debug returns additional debug data.
|
||||
func (mod *Chromium) Debug() map[string]any {
|
||||
debug := make(map[string]any)
|
||||
return map[string]any{"version": mod.detectVersion()}
|
||||
}
|
||||
|
||||
cmd := exec.Command(mod.args.binPath, "--version") //nolint:gosec
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
// detectVersion resolves the Chromium version once, preferring the value
|
||||
// captured at image build time so it never spawns Chromium at runtime. It falls
|
||||
// back to running chromium --version for local or non-Docker builds.
|
||||
func (mod *Chromium) detectVersion() string {
|
||||
mod.versionOnce.Do(func() {
|
||||
if v, ok := gotenberg.BuildVersion("chromium"); ok {
|
||||
mod.version = v
|
||||
return
|
||||
}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
debug["version"] = err.Error()
|
||||
return debug
|
||||
cmd := exec.Command(mod.args.binPath, "--version") //nolint:gosec
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
mod.version = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
mod.version = strings.TrimSpace(string(output))
|
||||
})
|
||||
|
||||
return mod.version
|
||||
}
|
||||
|
||||
// spanAttrs returns the client-span attributes for a Chromium invocation: the
|
||||
// server address and the Chromium version, plus any extra attributes. The
|
||||
// version rides on every conversion span so a trace records which Chromium
|
||||
// rendered the document.
|
||||
func (mod *Chromium) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue {
|
||||
attrs := make([]attribute.KeyValue, 0, 2+len(extra))
|
||||
attrs = append(attrs, semconv.ServerAddress(mod.args.binPath))
|
||||
if v := mod.detectVersion(); v != "" {
|
||||
attrs = append(attrs, attribute.String("gotenberg.chromium.version", v))
|
||||
}
|
||||
|
||||
debug["version"] = strings.TrimSpace(string(output))
|
||||
return debug
|
||||
return append(attrs, extra...)
|
||||
}
|
||||
|
||||
// Metrics returns the metrics.
|
||||
@@ -787,19 +853,32 @@ func (mod *Chromium) Routes() ([]api.Route, error) {
|
||||
}
|
||||
|
||||
// Pdf converts a URL to PDF.
|
||||
//
|
||||
//nolint:dupl
|
||||
func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
|
||||
// Read input attributes before Start rebinds ctx to the span context, which
|
||||
// would shadow the underlying [api.Context].
|
||||
inputAttrs := conversionInputAttrs(ctx, url)
|
||||
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Pdf",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)),
|
||||
trace.WithAttributes(mod.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
span.SetAttributes(inputAttrs...)
|
||||
span.SetAttributes(
|
||||
attribute.Int64("gotenberg.queue.depth_at_arrival", mod.supervisor.ReqQueueSize()),
|
||||
attribute.Int64("gotenberg.conversions_since_last_restart", mod.supervisor.ConversionsSinceRestart()),
|
||||
)
|
||||
|
||||
start := time.Now()
|
||||
var conversionStart time.Time
|
||||
|
||||
aggregate := newNetworkAggregate()
|
||||
err := mod.supervisor.Run(ctx, logger, func() error {
|
||||
conversionStart = time.Now()
|
||||
return mod.browser.pdf(ctx, logger, url, outputPath, options)
|
||||
return mod.browser.pdf(ctx, logger, url, outputPath, options, aggregate)
|
||||
})
|
||||
|
||||
end := time.Now()
|
||||
@@ -812,21 +891,12 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
|
||||
status = "error"
|
||||
}
|
||||
|
||||
reason := "unknown"
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
reason = "timeout"
|
||||
case errors.Is(err, context.Canceled):
|
||||
reason = "context_cancelled"
|
||||
case errors.Is(err, ErrInvalidHttpStatusCode) || errors.Is(err, ErrInvalidResourceHttpStatusCode) || errors.Is(err, ErrLoadingFailed) || errors.Is(err, ErrResourceLoadingFailed) || errors.Is(err, ErrInvalidEvaluationExpression) || errors.Is(err, ErrInvalidSelectorQuery):
|
||||
reason = "invalid_input"
|
||||
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) || errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
|
||||
reason = "chromium_unavailable"
|
||||
}
|
||||
reason := chromiumErrorType(err, "chromium_unavailable")
|
||||
|
||||
mod.errsCounter.Add(ctx, 1, metric.WithAttributes(
|
||||
attribute.String("reason", reason),
|
||||
))
|
||||
gotenberg.SpanErrorType(span, reason)
|
||||
}
|
||||
|
||||
if !conversionStart.IsZero() {
|
||||
@@ -850,9 +920,12 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
|
||||
attribute.String("status", status),
|
||||
))
|
||||
|
||||
mod.recordNetwork(ctx, span, aggregate)
|
||||
|
||||
if err == nil {
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
|
||||
mod.pdfOutputSizeCounter.Record(ctx, fileInfo.Size())
|
||||
span.SetAttributes(attribute.Int64("gotenberg.conversion.output.bytes", fileInfo.Size()))
|
||||
}
|
||||
|
||||
span.SetStatus(codes.Ok, "")
|
||||
@@ -864,19 +937,28 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa
|
||||
return err
|
||||
}
|
||||
|
||||
// Screenshot captures a screenshot from a URL.
|
||||
//
|
||||
//nolint:dupl
|
||||
func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Screenshot",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)),
|
||||
trace.WithAttributes(mod.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
span.SetAttributes(
|
||||
attribute.Int64("gotenberg.queue.depth_at_arrival", mod.supervisor.ReqQueueSize()),
|
||||
attribute.Int64("gotenberg.conversions_since_last_restart", mod.supervisor.ConversionsSinceRestart()),
|
||||
)
|
||||
|
||||
start := time.Now()
|
||||
var conversionStart time.Time
|
||||
|
||||
aggregate := newNetworkAggregate()
|
||||
err := mod.supervisor.Run(ctx, logger, func() error {
|
||||
conversionStart = time.Now()
|
||||
return mod.browser.screenshot(ctx, logger, url, outputPath, options)
|
||||
return mod.browser.screenshot(ctx, logger, url, outputPath, options, aggregate)
|
||||
})
|
||||
|
||||
end := time.Now()
|
||||
@@ -889,23 +971,12 @@ func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, o
|
||||
status = "error"
|
||||
}
|
||||
|
||||
reason := "unknown"
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
reason = "timeout"
|
||||
case errors.Is(err, context.Canceled):
|
||||
reason = "context_cancelled"
|
||||
case errors.Is(err, ErrInvalidHttpStatusCode) || errors.Is(err, ErrInvalidResourceHttpStatusCode) || errors.Is(err, ErrLoadingFailed) || errors.Is(err, ErrResourceLoadingFailed) || errors.Is(err, ErrInvalidEvaluationExpression) || errors.Is(err, ErrInvalidSelectorQuery):
|
||||
reason = "invalid_input"
|
||||
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded):
|
||||
reason = "chromium_maximum_queue_size_exceeded"
|
||||
case errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
|
||||
reason = "chromium_unavailable"
|
||||
}
|
||||
reason := chromiumErrorType(err, "chromium_maximum_queue_size_exceeded")
|
||||
|
||||
mod.errsCounter.Add(ctx, 1, metric.WithAttributes(
|
||||
attribute.String("reason", reason),
|
||||
))
|
||||
gotenberg.SpanErrorType(span, reason)
|
||||
}
|
||||
|
||||
if !conversionStart.IsZero() {
|
||||
@@ -929,6 +1000,8 @@ func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, o
|
||||
attribute.String("status", status),
|
||||
))
|
||||
|
||||
mod.recordNetwork(ctx, span, aggregate)
|
||||
|
||||
if err == nil {
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
|
||||
mod.imageOutputSizeCounter.Record(ctx, fileInfo.Size())
|
||||
@@ -943,6 +1016,86 @@ func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, o
|
||||
return err
|
||||
}
|
||||
|
||||
// recordNetwork lifts per-conversion network aggregates onto the span and the
|
||||
// network metrics. Counts are dimensioned by outcome and bytes feed a
|
||||
// histogram; both are recorded with the conversion context so the SDK attaches
|
||||
// trace exemplars. The heaviest resource URL is redacted before it lands on the
|
||||
// span event.
|
||||
func (mod *Chromium) recordNetwork(ctx context.Context, span trace.Span, aggregate *networkAggregate) {
|
||||
if aggregate == nil {
|
||||
return
|
||||
}
|
||||
|
||||
stats := aggregate.snapshot()
|
||||
|
||||
span.SetAttributes(
|
||||
attribute.Int64("gotenberg.chromium.resources.count", stats.requestCount),
|
||||
attribute.Int64("gotenberg.chromium.resources.bytes_total", stats.bytesTotal),
|
||||
attribute.Int64("gotenberg.chromium.resources.failed_count", stats.failedCount),
|
||||
attribute.Int64("gotenberg.chromium.resources.unique_origins", stats.uniqueOrigins),
|
||||
)
|
||||
|
||||
if stats.heaviestURL != "" {
|
||||
span.AddEvent("chromium.heaviest_resource", trace.WithAttributes(
|
||||
attribute.String("url", gotenberg.RedactURL(stats.heaviestURL)),
|
||||
attribute.Int64("bytes", stats.heaviestBytes),
|
||||
))
|
||||
}
|
||||
|
||||
if ok := stats.requestCount - stats.failedCount; ok > 0 {
|
||||
mod.networkRequestsCounter.Add(ctx, ok, metric.WithAttributes(attribute.String("outcome", "ok")))
|
||||
}
|
||||
if stats.failedCount > 0 {
|
||||
mod.networkRequestsCounter.Add(ctx, stats.failedCount, metric.WithAttributes(attribute.String("outcome", "failed")))
|
||||
}
|
||||
|
||||
mod.networkBytesCounter.Record(ctx, stats.bytesTotal)
|
||||
}
|
||||
|
||||
// conversionInputAttrs derives low-cardinality input attributes for a
|
||||
// conversion span: the number of received files (when ctx is an [api.Context])
|
||||
// and the size of the local HTML input (when url is a file:// URL). Remote URL
|
||||
// conversions yield no html.bytes.
|
||||
func conversionInputAttrs(ctx context.Context, url string) []attribute.KeyValue {
|
||||
var attrs []attribute.KeyValue
|
||||
|
||||
if apiCtx, ok := ctx.(*api.Context); ok {
|
||||
attrs = append(attrs, attribute.Int("gotenberg.conversion.input.files.count", apiCtx.FileCount()))
|
||||
}
|
||||
|
||||
if after, ok := strings.CutPrefix(url, "file://"); ok {
|
||||
if info, err := os.Stat(after); err == nil {
|
||||
attrs = append(attrs, attribute.Int64("gotenberg.conversion.input.html.bytes", info.Size()))
|
||||
}
|
||||
}
|
||||
|
||||
return attrs
|
||||
}
|
||||
|
||||
// chromiumErrorType maps a conversion error to chromium's bounded reason value,
|
||||
// reused as the span error.type. queueReason preserves the historical,
|
||||
// route-specific label for a saturated queue: Pdf collapses it into
|
||||
// "chromium_unavailable", whereas Screenshot keeps
|
||||
// "chromium_maximum_queue_size_exceeded". Generic failures fall back to
|
||||
// [gotenberg.ClassifyError].
|
||||
func chromiumErrorType(err error, queueReason string) string {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidHttpStatusCode),
|
||||
errors.Is(err, ErrInvalidResourceHttpStatusCode),
|
||||
errors.Is(err, ErrLoadingFailed),
|
||||
errors.Is(err, ErrResourceLoadingFailed),
|
||||
errors.Is(err, ErrInvalidEvaluationExpression),
|
||||
errors.Is(err, ErrInvalidSelectorQuery):
|
||||
return gotenberg.ErrorTypeInvalidInput
|
||||
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded):
|
||||
return queueReason
|
||||
case errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
|
||||
return "chromium_unavailable"
|
||||
default:
|
||||
return gotenberg.ClassifyError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*Chromium)(nil)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package chromium provides a module which adds routes for converting HTML
|
||||
// documents to PDF. Other modules may also retrieve the [Api] provided by this
|
||||
// module.
|
||||
// Package chromium adds routes for converting HTML documents to PDF. Exposes
|
||||
// an [Api] for other modules.
|
||||
package chromium
|
||||
|
||||
37
pkg/modules/chromium/errortype_test.go
Normal file
37
pkg/modules/chromium/errortype_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestChromiumErrorType(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
queueReason string
|
||||
want string
|
||||
}{
|
||||
{"deadline", context.DeadlineExceeded, "chromium_unavailable", "timeout"},
|
||||
{"canceled", context.Canceled, "chromium_unavailable", "context_cancelled"},
|
||||
{"invalid http status", ErrInvalidHttpStatusCode, "chromium_unavailable", "invalid_input"},
|
||||
{"invalid resource http status", ErrInvalidResourceHttpStatusCode, "chromium_unavailable", "invalid_input"},
|
||||
{"loading failed", ErrLoadingFailed, "chromium_unavailable", "invalid_input"},
|
||||
{"resource loading failed", ErrResourceLoadingFailed, "chromium_unavailable", "invalid_input"},
|
||||
{"invalid evaluation expression", ErrInvalidEvaluationExpression, "chromium_unavailable", "invalid_input"},
|
||||
{"invalid selector query", ErrInvalidSelectorQuery, "chromium_unavailable", "invalid_input"},
|
||||
{"pdf queue", gotenberg.ErrMaximumQueueSizeExceeded, "chromium_unavailable", "chromium_unavailable"},
|
||||
{"screenshot queue", gotenberg.ErrMaximumQueueSizeExceeded, "chromium_maximum_queue_size_exceeded", "chromium_maximum_queue_size_exceeded"},
|
||||
{"restarting", gotenberg.ErrProcessAlreadyRestarting, "chromium_maximum_queue_size_exceeded", "chromium_unavailable"},
|
||||
{"unknown", errors.New("boom"), "chromium_unavailable", "unknown"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := chromiumErrorType(tc.err, tc.queueReason); got != tc.want {
|
||||
t.Errorf("chromiumErrorType(%v, %q) = %q, want %q", tc.err, tc.queueReason, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,30 @@ import (
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
// listenForNetworkActivity accumulates per-conversion network activity into
|
||||
// aggregate from the always-on Network domain events. It is a no-op when
|
||||
// aggregate is nil.
|
||||
func listenForNetworkActivity(ctx context.Context, aggregate *networkAggregate) {
|
||||
if aggregate == nil {
|
||||
return
|
||||
}
|
||||
|
||||
chromedp.ListenTarget(ctx, func(ev any) {
|
||||
switch e := ev.(type) {
|
||||
case *network.EventResponseReceived:
|
||||
aggregate.onResponseReceived(e)
|
||||
case *network.EventLoadingFinished:
|
||||
aggregate.onLoadingFinished(e)
|
||||
case *network.EventLoadingFailed:
|
||||
aggregate.onLoadingFailed(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type eventRequestPausedOptions struct {
|
||||
allowList, denyList []*regexp2.Regexp
|
||||
denyPrivateIPs bool
|
||||
denyPublicIPs bool
|
||||
allowedFilePrefixes []string
|
||||
extraHttpHeaders []ExtraHttpHeader
|
||||
}
|
||||
@@ -52,35 +74,42 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
|
||||
return
|
||||
}
|
||||
|
||||
err := gotenberg.FilterDeadline(options.allowList, options.denyList, e.Request.URL, deadline)
|
||||
err := gotenberg.FilterOutboundURL(ctx, e.Request.URL, options.allowList, options.denyList, deadline,
|
||||
gotenberg.WithDenyPrivateIPs(options.denyPrivateIPs),
|
||||
gotenberg.WithDenyPublicIPs(options.denyPublicIPs),
|
||||
)
|
||||
if err != nil {
|
||||
logger.WarnContext(ctx, err.Error())
|
||||
allow = false
|
||||
}
|
||||
|
||||
// Additional restriction: if the sub-resource is a file:// URL
|
||||
// and we have allowed file prefixes, restrict access to only
|
||||
// those directories. This prevents cross-request file access
|
||||
// in /tmp.
|
||||
if allow && strings.HasPrefix(e.Request.URL, "file://") && len(options.allowedFilePrefixes) > 0 {
|
||||
prefixMatch := false
|
||||
for _, prefix := range options.allowedFilePrefixes {
|
||||
if strings.HasPrefix(e.Request.URL, "file://"+prefix) {
|
||||
prefixMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !prefixMatch {
|
||||
logger.WarnContext(ctx, fmt.Sprintf("'%s' is not within any allowed file prefix", e.Request.URL))
|
||||
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 {
|
||||
@@ -240,6 +269,23 @@ func listenForEventResponseReceived(
|
||||
})
|
||||
}
|
||||
|
||||
// isAllowedFileSubResource reports whether a file:// sub-resource URL is
|
||||
// within at least one prefix. An empty prefix list rejects every
|
||||
// file:// URL so routes that never populate the list (for example
|
||||
// /forms/chromium/convert/url) default-deny reads from /tmp/, blocking
|
||||
// cross-request enumeration.
|
||||
func isAllowedFileSubResource(rawURL string, allowedFilePrefixes []string) bool {
|
||||
if len(allowedFilePrefixes) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range allowedFilePrefixes {
|
||||
if strings.HasPrefix(rawURL, "file://"+prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shouldCheckResourceHttpStatusCode(rawURL string, ignoreDomains []string) bool {
|
||||
host := hostnameFromURL(rawURL)
|
||||
|
||||
@@ -402,19 +448,24 @@ func listenForEventExceptionThrown(ctx context.Context, logger *slog.Logger, con
|
||||
})
|
||||
}
|
||||
|
||||
// waitForEventDomContentEventFired waits until the event DomContentEventFired
|
||||
// is fired or the context timeout.
|
||||
// waitForEventDomContentEventFired registers a listener for the
|
||||
// DomContentEventFired event and returns a waiter that blocks until the
|
||||
// event fires or ctx is done. The listener registers at call time, not
|
||||
// inside the waiter, so callers must invoke this before triggering the
|
||||
// action that may emit the event. Registering inside the waiter would
|
||||
// open a race: for fast loads (typically file:// pages with no external
|
||||
// sub-resources), the event can fire before the waiter goroutine starts
|
||||
// and the listener never sees a record.
|
||||
func waitForEventDomContentEventFired(ctx context.Context, logger *slog.Logger) func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev any) {
|
||||
if _, ok := ev.(*page.EventDomContentEventFired); ok {
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
})
|
||||
return func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev any) {
|
||||
if _, ok := ev.(*page.EventDomContentEventFired); ok {
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
logger.DebugContext(ctx, "event DomContentEventFired fired")
|
||||
@@ -425,19 +476,20 @@ func waitForEventDomContentEventFired(ctx context.Context, logger *slog.Logger)
|
||||
}
|
||||
}
|
||||
|
||||
// waitForEventLoadEventFired waits until the event LoadEventFired is fired or
|
||||
// the context timeout.
|
||||
// waitForEventLoadEventFired registers a listener for the LoadEventFired
|
||||
// event and returns a waiter that blocks until the event fires or ctx is
|
||||
// done. See [waitForEventDomContentEventFired] for the rationale on
|
||||
// registering at call time rather than inside the waiter.
|
||||
func waitForEventLoadEventFired(ctx context.Context, logger *slog.Logger) func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev any) {
|
||||
if _, ok := ev.(*page.EventLoadEventFired); ok {
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
})
|
||||
return func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev any) {
|
||||
if _, ok := ev.(*page.EventLoadEventFired); ok {
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
logger.DebugContext(ctx, "event LoadEventFired fired")
|
||||
@@ -448,19 +500,20 @@ func waitForEventLoadEventFired(ctx context.Context, logger *slog.Logger) func()
|
||||
}
|
||||
}
|
||||
|
||||
// waitForEventNetworkIdle waits until the event networkIdle is fired or the
|
||||
// context timeout.
|
||||
// waitForEventNetworkIdle registers a listener for the networkIdle
|
||||
// lifecycle event and returns a waiter that blocks until the event fires
|
||||
// or ctx is done. See [waitForEventDomContentEventFired] for the
|
||||
// rationale on registering at call time rather than inside the waiter.
|
||||
func waitForEventNetworkIdle(ctx context.Context, logger *slog.Logger) func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev any) {
|
||||
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle" {
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
})
|
||||
return func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev any) {
|
||||
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle" {
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
logger.DebugContext(ctx, "event networkIdle fired")
|
||||
@@ -471,19 +524,20 @@ func waitForEventNetworkIdle(ctx context.Context, logger *slog.Logger) func() er
|
||||
}
|
||||
}
|
||||
|
||||
// waitForEventNetworkAlmostIdle waits until the event networkIdle2 is fired
|
||||
// or the context timeout.
|
||||
// waitForEventNetworkAlmostIdle registers a listener for the networkIdle2
|
||||
// lifecycle event and returns a waiter that blocks until the event fires
|
||||
// or ctx is done. See [waitForEventDomContentEventFired] for the
|
||||
// rationale on registering at call time rather than inside the waiter.
|
||||
func waitForEventNetworkAlmostIdle(ctx context.Context, logger *slog.Logger) func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev any) {
|
||||
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle2" {
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
})
|
||||
return func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev any) {
|
||||
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle2" {
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
logger.DebugContext(ctx, "event networkAlmostIdle fired")
|
||||
@@ -494,19 +548,20 @@ func waitForEventNetworkAlmostIdle(ctx context.Context, logger *slog.Logger) fun
|
||||
}
|
||||
}
|
||||
|
||||
// waitForEventLoadingFinished waits until the event LoadingFinished is fired
|
||||
// or the context timeout.
|
||||
// waitForEventLoadingFinished registers a listener for the
|
||||
// LoadingFinished event and returns a waiter that blocks until the event
|
||||
// fires or ctx is done. See [waitForEventDomContentEventFired] for the
|
||||
// rationale on registering at call time rather than inside the waiter.
|
||||
func waitForEventLoadingFinished(ctx context.Context, logger *slog.Logger) func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev any) {
|
||||
if _, ok := ev.(*network.EventLoadingFinished); ok {
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
})
|
||||
return func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev any) {
|
||||
if _, ok := ev.(*network.EventLoadingFinished); ok {
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
logger.DebugContext(ctx, "event LoadingFinished fired")
|
||||
|
||||
@@ -61,3 +61,49 @@ func TestShouldCheckResourceHttpStatusCode_NonHTTPURL(t *testing.T) {
|
||||
t.Fatalf("expected data: URL to be checked (no host filtering possible)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAllowedFileSubResource(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
rawURL string
|
||||
prefixes []string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "empty prefix list default denies",
|
||||
rawURL: "file:///tmp/work-uuid/request-uuid/index.html",
|
||||
prefixes: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "match within the sole prefix",
|
||||
rawURL: "file:///tmp/work-uuid/request-uuid/index.html",
|
||||
prefixes: []string{"/tmp/work-uuid/request-uuid"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "sibling request directory rejected",
|
||||
rawURL: "file:///tmp/work-uuid/other-request-uuid/secret.html",
|
||||
prefixes: []string{"/tmp/work-uuid/request-uuid"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "parent tmp directory rejected",
|
||||
rawURL: "file:///tmp/",
|
||||
prefixes: []string{"/tmp/work-uuid/request-uuid"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "match among several prefixes",
|
||||
rawURL: "file:///tmp/work-uuid/request-b/asset.css",
|
||||
prefixes: []string{"/tmp/work-uuid/request-a", "/tmp/work-uuid/request-b"},
|
||||
want: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isAllowedFileSubResource(tc.rawURL, tc.prefixes); got != tc.want {
|
||||
t.Fatalf("isAllowedFileSubResource(%q, %v) = %v, want %v", tc.rawURL, tc.prefixes, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,16 +24,16 @@ func (api *ApiMock) Screenshot(ctx context.Context, logger *slog.Logger, url, ou
|
||||
// browserMock is a mock for the [browser] interface.
|
||||
type browserMock struct {
|
||||
gotenberg.ProcessMock
|
||||
pdfMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error
|
||||
screenshotMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error
|
||||
pdfMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions, aggregate *networkAggregate) error
|
||||
screenshotMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions, aggregate *networkAggregate) error
|
||||
}
|
||||
|
||||
func (b *browserMock) pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
|
||||
return b.pdfMock(ctx, logger, url, outputPath, options)
|
||||
func (b *browserMock) pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions, aggregate *networkAggregate) error {
|
||||
return b.pdfMock(ctx, logger, url, outputPath, options, aggregate)
|
||||
}
|
||||
|
||||
func (b *browserMock) screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
return b.screenshotMock(ctx, logger, url, outputPath, options)
|
||||
func (b *browserMock) screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions, aggregate *networkAggregate) error {
|
||||
return b.screenshotMock(ctx, logger, url, outputPath, options, aggregate)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
|
||||
121
pkg/modules/chromium/network_aggregate.go
Normal file
121
pkg/modules/chromium/network_aggregate.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"sync"
|
||||
|
||||
"github.com/chromedp/cdproto/network"
|
||||
)
|
||||
|
||||
// maxTrackedOrigins bounds the distinct origins kept per conversion so a
|
||||
// pathological page cannot grow the set without limit.
|
||||
const maxTrackedOrigins = 64
|
||||
|
||||
// networkAggregate accumulates per-conversion network activity from Chromium
|
||||
// DevTools events. It is safe for concurrent use by the chromedp event listener
|
||||
// goroutine and the conversion goroutine that reads the snapshot afterwards.
|
||||
type networkAggregate struct {
|
||||
mu sync.Mutex
|
||||
|
||||
requestCount int64
|
||||
bytesTotal int64
|
||||
failedCount int64
|
||||
|
||||
origins map[string]struct{}
|
||||
requestURLByID map[network.RequestID]string
|
||||
|
||||
heaviestURL string
|
||||
heaviestBytes int64
|
||||
}
|
||||
|
||||
// networkStats is an immutable snapshot of a [networkAggregate].
|
||||
type networkStats struct {
|
||||
requestCount int64
|
||||
bytesTotal int64
|
||||
failedCount int64
|
||||
uniqueOrigins int64
|
||||
heaviestURL string
|
||||
heaviestBytes int64
|
||||
}
|
||||
|
||||
func newNetworkAggregate() *networkAggregate {
|
||||
return &networkAggregate{
|
||||
origins: make(map[string]struct{}),
|
||||
requestURLByID: make(map[network.RequestID]string),
|
||||
}
|
||||
}
|
||||
|
||||
// onResponseReceived records the response origin and remembers the URL for the
|
||||
// request id, so a later loading-finished event can attribute its bytes.
|
||||
func (a *networkAggregate) onResponseReceived(ev *network.EventResponseReceived) {
|
||||
if ev == nil || ev.Response == nil {
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if origin := originOf(ev.Response.URL); origin != "" {
|
||||
if _, ok := a.origins[origin]; !ok && len(a.origins) < maxTrackedOrigins {
|
||||
a.origins[origin] = struct{}{}
|
||||
}
|
||||
}
|
||||
a.requestURLByID[ev.RequestID] = ev.Response.URL
|
||||
}
|
||||
|
||||
// onLoadingFinished records a successfully completed request and its size,
|
||||
// tracking the single heaviest resource.
|
||||
func (a *networkAggregate) onLoadingFinished(ev *network.EventLoadingFinished) {
|
||||
if ev == nil {
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
a.requestCount++
|
||||
|
||||
size := int64(ev.EncodedDataLength)
|
||||
a.bytesTotal += size
|
||||
if size > a.heaviestBytes {
|
||||
a.heaviestBytes = size
|
||||
a.heaviestURL = a.requestURLByID[ev.RequestID]
|
||||
}
|
||||
}
|
||||
|
||||
// onLoadingFailed records a request that failed to complete.
|
||||
func (a *networkAggregate) onLoadingFailed(ev *network.EventLoadingFailed) {
|
||||
if ev == nil {
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
a.requestCount++
|
||||
a.failedCount++
|
||||
}
|
||||
|
||||
func (a *networkAggregate) snapshot() networkStats {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
return networkStats{
|
||||
requestCount: a.requestCount,
|
||||
bytesTotal: a.bytesTotal,
|
||||
failedCount: a.failedCount,
|
||||
uniqueOrigins: int64(len(a.origins)),
|
||||
heaviestURL: a.heaviestURL,
|
||||
heaviestBytes: a.heaviestBytes,
|
||||
}
|
||||
}
|
||||
|
||||
// originOf returns the scheme://host of rawURL, or an empty string when it has
|
||||
// no host (for example data: or file: URLs).
|
||||
func originOf(rawURL string) string {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return parsed.Scheme + "://" + parsed.Host
|
||||
}
|
||||
100
pkg/modules/chromium/network_aggregate_test.go
Normal file
100
pkg/modules/chromium/network_aggregate_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/chromedp/cdproto/network"
|
||||
)
|
||||
|
||||
func TestOriginOf(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{"https://example.com/path?q=1", "https://example.com"},
|
||||
{"http://cdn.example.com:8080/a.js", "http://cdn.example.com:8080"},
|
||||
{"data:image/png;base64,AAAA", ""},
|
||||
{"file:///tmp/index.html", ""},
|
||||
{"not a url", ""},
|
||||
} {
|
||||
if got := originOf(tc.raw); got != tc.want {
|
||||
t.Errorf("originOf(%q) = %q, want %q", tc.raw, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkAggregate_Snapshot(t *testing.T) {
|
||||
a := newNetworkAggregate()
|
||||
|
||||
a.onResponseReceived(&network.EventResponseReceived{
|
||||
RequestID: "1",
|
||||
Response: &network.Response{URL: "https://example.com/a.js"},
|
||||
})
|
||||
a.onResponseReceived(&network.EventResponseReceived{
|
||||
RequestID: "2",
|
||||
Response: &network.Response{URL: "https://cdn.example.com/b.png"},
|
||||
})
|
||||
// Duplicate origin must not grow the set.
|
||||
a.onResponseReceived(&network.EventResponseReceived{
|
||||
RequestID: "3",
|
||||
Response: &network.Response{URL: "https://example.com/c.css"},
|
||||
})
|
||||
|
||||
a.onLoadingFinished(&network.EventLoadingFinished{RequestID: "1", EncodedDataLength: 100})
|
||||
a.onLoadingFinished(&network.EventLoadingFinished{RequestID: "2", EncodedDataLength: 900})
|
||||
a.onLoadingFailed(&network.EventLoadingFailed{RequestID: "3"})
|
||||
|
||||
got := a.snapshot()
|
||||
if got.requestCount != 3 {
|
||||
t.Errorf("requestCount = %d, want 3", got.requestCount)
|
||||
}
|
||||
if got.bytesTotal != 1000 {
|
||||
t.Errorf("bytesTotal = %d, want 1000", got.bytesTotal)
|
||||
}
|
||||
if got.failedCount != 1 {
|
||||
t.Errorf("failedCount = %d, want 1", got.failedCount)
|
||||
}
|
||||
if got.uniqueOrigins != 2 {
|
||||
t.Errorf("uniqueOrigins = %d, want 2", got.uniqueOrigins)
|
||||
}
|
||||
if got.heaviestBytes != 900 || got.heaviestURL != "https://cdn.example.com/b.png" {
|
||||
t.Errorf("heaviest = (%q, %d), want (%q, 900)", got.heaviestURL, got.heaviestBytes, "https://cdn.example.com/b.png")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkAggregate_OriginCap(t *testing.T) {
|
||||
a := newNetworkAggregate()
|
||||
for i := range maxTrackedOrigins + 50 {
|
||||
a.onResponseReceived(&network.EventResponseReceived{
|
||||
RequestID: network.RequestID(fmt.Sprintf("r%d", i)),
|
||||
Response: &network.Response{URL: fmt.Sprintf("https://host%d.example.com/x", i)},
|
||||
})
|
||||
}
|
||||
if got := a.snapshot().uniqueOrigins; got != maxTrackedOrigins {
|
||||
t.Errorf("uniqueOrigins = %d, want %d (capped)", got, maxTrackedOrigins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkAggregate_ConcurrentSafe(t *testing.T) {
|
||||
a := newNetworkAggregate()
|
||||
var wg sync.WaitGroup
|
||||
for i := range 100 {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
id := network.RequestID(fmt.Sprintf("r%d", i))
|
||||
a.onResponseReceived(&network.EventResponseReceived{
|
||||
RequestID: id,
|
||||
Response: &network.Response{URL: fmt.Sprintf("https://host%d.example.com/x", i)},
|
||||
})
|
||||
a.onLoadingFinished(&network.EventLoadingFinished{RequestID: id, EncodedDataLength: 10})
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if got := a.snapshot().requestCount; got != 100 {
|
||||
t.Errorf("requestCount = %d, want 100", got)
|
||||
}
|
||||
}
|
||||
426
pkg/modules/chromium/pinning_proxy.go
Normal file
426
pkg/modules/chromium/pinning_proxy.go
Normal file
@@ -0,0 +1,426 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"golang.org/x/net/http/httpproxy"
|
||||
|
||||
"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)
|
||||
|
||||
// upstreamProxy resolves the upstream (corporate) proxy for a
|
||||
// destination URL from the standard proxy environment variables, or
|
||||
// returns a nil URL to connect directly. It is nil unless the operator
|
||||
// opted into proxy-environment honoring. When set, the pinning proxy
|
||||
// performs the authenticated proxy handshake that Chromium cannot. See
|
||||
// https://github.com/gotenberg/gotenberg/issues/1592.
|
||||
upstreamProxy func(*url.URL) (*url.URL, error)
|
||||
|
||||
listener net.Listener
|
||||
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, enableEnvironmentProxy bool) *pinningProxy {
|
||||
p := &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)
|
||||
},
|
||||
}
|
||||
|
||||
if enableEnvironmentProxy {
|
||||
// Honor the standard proxy environment variables, credentials
|
||||
// included. httpproxy reads the environment now and applies NO_PROXY.
|
||||
p.upstreamProxy = httpproxy.FromEnvironment().ProxyFunc()
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// Start binds the proxy to 127.0.0.1 on an ephemeral port and serves in a
|
||||
// background goroutine. Bind failures return an error; the caller must
|
||||
// not proceed to start Chromium with --proxy-server.
|
||||
func (p *pinningProxy) Start(logger *slog.Logger) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.started {
|
||||
return errors.New("pinning proxy already started")
|
||||
}
|
||||
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return fmt.Errorf("bind pinning proxy: %w", err)
|
||||
}
|
||||
|
||||
p.listener = l
|
||||
p.logger = logger.With(slog.String("logger", "pinning-proxy"))
|
||||
p.server = &http.Server{
|
||||
Handler: http.HandlerFunc(p.serveHTTP),
|
||||
// Guard against slow header attacks. Body reads are controlled
|
||||
// per-handler.
|
||||
ReadHeaderTimeout: 15 * time.Second,
|
||||
ErrorLog: slog.NewLogLogger(p.logger.Handler(), slog.LevelWarn),
|
||||
}
|
||||
|
||||
p.wg.Go(func() {
|
||||
serveErr := p.server.Serve(l)
|
||||
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
|
||||
p.logger.ErrorContext(context.Background(), fmt.Sprintf("pinning proxy serve: %s", serveErr))
|
||||
}
|
||||
})
|
||||
|
||||
p.started = true
|
||||
p.logger.DebugContext(context.Background(), fmt.Sprintf("pinning proxy listening on %s", l.Addr()))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop shuts the proxy down and waits for in-flight handlers to complete.
|
||||
// Safe to call on a non-started proxy.
|
||||
func (p *pinningProxy) Stop(logger *slog.Logger) error {
|
||||
p.mu.Lock()
|
||||
if !p.started {
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
srv := p.server
|
||||
p.started = false
|
||||
p.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
shutdownErr := srv.Shutdown(ctx)
|
||||
p.wg.Wait()
|
||||
|
||||
if shutdownErr != nil {
|
||||
return fmt.Errorf("shutdown pinning proxy: %w", shutdownErr)
|
||||
}
|
||||
logger.DebugContext(context.Background(), "pinning proxy stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// URL returns the proxy URL suitable for Chromium's --proxy-server flag.
|
||||
// Returns an empty string when the proxy is not listening.
|
||||
func (p *pinningProxy) URL() string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.listener == nil {
|
||||
return ""
|
||||
}
|
||||
return "http://" + p.listener.Addr().String()
|
||||
}
|
||||
|
||||
func (p *pinningProxy) serveHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method == http.MethodConnect {
|
||||
p.handleConnect(w, req)
|
||||
return
|
||||
}
|
||||
p.handleForward(w, req)
|
||||
}
|
||||
|
||||
// handleConnect handles HTTPS (and any other CONNECT) tunnels. Chromium
|
||||
// issues CONNECT host:port; the proxy validates the host, dials the
|
||||
// pinned IP, and splices the client socket with the upstream socket.
|
||||
// Chromium then negotiates TLS end-to-end with the original hostname in
|
||||
// SNI.
|
||||
func (p *pinningProxy) handleConnect(w http.ResponseWriter, req *http.Request) {
|
||||
_, port, err := net.SplitHostPort(req.Host)
|
||||
if err != nil {
|
||||
http.Error(w, "bad CONNECT target", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deadline, ok := req.Context().Deadline()
|
||||
if !ok {
|
||||
deadline = time.Now().Add(30 * time.Second)
|
||||
}
|
||||
|
||||
// The validation URL uses https:// so that http-like scheme checks
|
||||
// apply in [gotenberg.DecideOutbound]. The scheme does not influence
|
||||
// the CONNECT handling beyond filtering.
|
||||
decision, err := p.decide(req.Context(), "https://"+req.Host, p.allowList, p.denyList, deadline)
|
||||
if err != nil {
|
||||
if isClientCancellation(req.Context(), err) {
|
||||
p.logger.DebugContext(req.Context(), fmt.Sprintf("CONNECT abandoned by client for '%s': %s", req.Host, err))
|
||||
} else {
|
||||
p.logger.WarnContext(req.Context(), fmt.Sprintf("CONNECT blocked for '%s': %s", req.Host, err))
|
||||
}
|
||||
http.Error(w, "CONNECT blocked", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// When the operator routes egress through an authenticated proxy,
|
||||
// Chromium cannot supply the credentials itself, so the pinning proxy
|
||||
// performs the CONNECT (and authentication) upstream. The decision above
|
||||
// still gated the destination through the allow/deny and IP-class rules.
|
||||
var proxyURL *url.URL
|
||||
if p.upstreamProxy != nil {
|
||||
proxyURL, err = p.upstreamProxy(&url.URL{Scheme: "https", Host: req.Host})
|
||||
if err != nil {
|
||||
p.logger.WarnContext(req.Context(), fmt.Sprintf("resolve upstream proxy for '%s': %s", req.Host, err))
|
||||
http.Error(w, "upstream proxy error", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var upstream net.Conn
|
||||
switch {
|
||||
case proxyURL != nil:
|
||||
upstream, err = p.dialThroughUpstreamProxy(req.Context(), proxyURL, req.Host)
|
||||
case decision.Bypass:
|
||||
upstream, err = p.dialBypass(req.Context(), "tcp", req.Host)
|
||||
case len(decision.Pinned) > 0:
|
||||
upstream, err = p.dialPinned(req.Context(), "tcp", decision.Pinned, port)
|
||||
default:
|
||||
err = errors.New("no pinned addresses and not bypassed")
|
||||
}
|
||||
if err != nil {
|
||||
if isClientCancellation(req.Context(), err) {
|
||||
p.logger.DebugContext(req.Context(), fmt.Sprintf("CONNECT dial abandoned by client for '%s': %s", req.Host, err))
|
||||
} else {
|
||||
p.logger.WarnContext(req.Context(), fmt.Sprintf("CONNECT dial failed for '%s': %s", req.Host, err))
|
||||
}
|
||||
http.Error(w, "upstream dial failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer upstream.Close()
|
||||
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "hijack unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
client, _, err := hj.Hijack()
|
||||
if err != nil {
|
||||
p.logger.ErrorContext(req.Context(), fmt.Sprintf("hijack CONNECT: %s", err))
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
_, err = client.Write([]byte("HTTP/1.1 200 OK\r\n\r\n"))
|
||||
if err != nil {
|
||||
if isClientCancellation(req.Context(), err) {
|
||||
p.logger.DebugContext(req.Context(), fmt.Sprintf("write CONNECT ack abandoned by client: %s", err))
|
||||
} else {
|
||||
p.logger.WarnContext(req.Context(), fmt.Sprintf("write CONNECT ack: %s", err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Splice bytes in both directions until either side closes.
|
||||
var splice sync.WaitGroup
|
||||
splice.Add(2)
|
||||
go func() {
|
||||
defer splice.Done()
|
||||
_, _ = io.Copy(upstream, client)
|
||||
if cw, ok := upstream.(interface{ CloseWrite() error }); ok {
|
||||
_ = cw.CloseWrite()
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer splice.Done()
|
||||
_, _ = io.Copy(client, upstream)
|
||||
if cw, ok := client.(interface{ CloseWrite() error }); ok {
|
||||
_ = cw.CloseWrite()
|
||||
}
|
||||
}()
|
||||
splice.Wait()
|
||||
}
|
||||
|
||||
// handleForward handles plain HTTP requests sent to the proxy as absolute
|
||||
// URIs (GET http://host/path). The proxy revalidates the URL, then
|
||||
// forwards the request via a transport that dials the pinned IP.
|
||||
func (p *pinningProxy) handleForward(w http.ResponseWriter, req *http.Request) {
|
||||
if req.URL == nil || req.URL.Scheme == "" || req.URL.Host == "" {
|
||||
http.Error(w, "absolute URL required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
deadline, ok := req.Context().Deadline()
|
||||
if !ok {
|
||||
deadline = time.Now().Add(30 * time.Second)
|
||||
}
|
||||
|
||||
decision, err := p.decide(req.Context(), req.URL.String(), p.allowList, p.denyList, deadline)
|
||||
if err != nil {
|
||||
if isClientCancellation(req.Context(), err) {
|
||||
p.logger.DebugContext(req.Context(), fmt.Sprintf("forward abandoned by client for '%s': %s", req.URL, err))
|
||||
} else {
|
||||
p.logger.WarnContext(req.Context(), fmt.Sprintf("forward blocked for '%s': %s", req.URL, err))
|
||||
}
|
||||
http.Error(w, "request blocked", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var proxyURL *url.URL
|
||||
if p.upstreamProxy != nil {
|
||||
proxyURL, err = p.upstreamProxy(req.URL)
|
||||
if err != nil {
|
||||
p.logger.WarnContext(req.Context(), fmt.Sprintf("resolve upstream proxy for '%s': %s", req.URL.Redacted(), err))
|
||||
http.Error(w, "upstream proxy error", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
outReq := req.Clone(req.Context())
|
||||
outReq.RequestURI = ""
|
||||
stripHopByHopHeaders(outReq.Header)
|
||||
|
||||
// Build a fresh transport per request. The decision contains the pinned
|
||||
// IPs to dial; reusing a transport across requests would leak the
|
||||
// decision's closure across unrelated targets.
|
||||
transport := &http.Transport{
|
||||
DisableKeepAlives: true,
|
||||
}
|
||||
if proxyURL != nil {
|
||||
// The upstream proxy owns DNS and egress; Go adds Proxy-Authorization
|
||||
// from the URL's credentials. The decision above already gated the
|
||||
// destination, and dialBypass dials the proxy host directly.
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
transport.DialContext = p.dialBypass
|
||||
} else {
|
||||
transport.Proxy = nil
|
||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
_, port, splitErr := net.SplitHostPort(addr)
|
||||
if splitErr != nil {
|
||||
return nil, fmt.Errorf("split forward addr %q: %w", addr, splitErr)
|
||||
}
|
||||
switch {
|
||||
case decision.Bypass:
|
||||
return p.dialBypass(ctx, network, addr)
|
||||
case len(decision.Pinned) > 0:
|
||||
return p.dialPinned(ctx, network, decision.Pinned, port)
|
||||
default:
|
||||
return nil, errors.New("no pinned addresses and not bypassed")
|
||||
}
|
||||
}
|
||||
}
|
||||
defer transport.CloseIdleConnections()
|
||||
|
||||
resp, err := transport.RoundTrip(outReq)
|
||||
if err != nil {
|
||||
if isClientCancellation(req.Context(), err) {
|
||||
p.logger.DebugContext(req.Context(), fmt.Sprintf("forward RoundTrip abandoned by client for '%s': %s", req.URL, err))
|
||||
} else {
|
||||
p.logger.WarnContext(req.Context(), fmt.Sprintf("forward RoundTrip failed for '%s': %s", req.URL, err))
|
||||
}
|
||||
http.Error(w, "upstream error", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
copyHeaders(w.Header(), resp.Header)
|
||||
stripHopByHopHeaders(w.Header())
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
// Per RFC 7230 section 6.1.
|
||||
var hopByHopHeaders = []string{
|
||||
"Connection",
|
||||
"Keep-Alive",
|
||||
"Proxy-Authenticate",
|
||||
"Proxy-Authorization",
|
||||
"Proxy-Connection",
|
||||
"Te",
|
||||
"Trailer",
|
||||
"Transfer-Encoding",
|
||||
"Upgrade",
|
||||
}
|
||||
|
||||
func stripHopByHopHeaders(h http.Header) {
|
||||
for _, name := range hopByHopHeaders {
|
||||
h.Del(name)
|
||||
}
|
||||
}
|
||||
|
||||
func copyHeaders(dst, src http.Header) {
|
||||
for k, vs := range src {
|
||||
for _, v := range vs {
|
||||
dst.Add(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isClientCancellation reports whether err originates from the client (for
|
||||
// example Chromium) closing the connection or letting the request deadline
|
||||
// pass before the proxy could finish validating the destination. Such
|
||||
// errors are not policy refusals: the proxy never reached an allow/deny
|
||||
// rule decision. Callers downgrade these to debug to avoid alarming
|
||||
// operators with noise from speculative or aborted browser requests. The
|
||||
// canonical case is a Chromium DNS prefetch that the browser drops before
|
||||
// the proxy's [outbound.resolveHost] call returns. The [net.DNSError]
|
||||
// returned by [net.Resolver.LookupNetIP] unwraps to [context.Canceled] or
|
||||
// [context.DeadlineExceeded] in that case, so an [errors.Is] walk catches
|
||||
// it.
|
||||
func isClientCancellation(ctx context.Context, err error) bool {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
return ctx.Err() != nil
|
||||
}
|
||||
|
||||
// dialThroughUpstreamProxy tunnels to target through the upstream proxy,
|
||||
// letting [gotenberg.DialThroughProxy] perform the authenticated CONNECT that
|
||||
// Chromium cannot. dialBypass dials the proxy itself and is overridable in
|
||||
// tests. See https://github.com/gotenberg/gotenberg/issues/1592.
|
||||
func (p *pinningProxy) dialThroughUpstreamProxy(ctx context.Context, proxyURL *url.URL, target string) (net.Conn, error) {
|
||||
return gotenberg.DialThroughProxy(ctx, proxyURL, target, p.dialBypass)
|
||||
}
|
||||
900
pkg/modules/chromium/pinning_proxy_test.go
Normal file
900
pkg/modules/chromium/pinning_proxy_test.go
Normal file
@@ -0,0 +1,900 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
// recordingHandler is a slog.Handler that captures every record emitted
|
||||
// through it so tests can assert on the level and message of proxy logs.
|
||||
type recordingHandler struct {
|
||||
mu sync.Mutex
|
||||
records []slog.Record
|
||||
}
|
||||
|
||||
func (h *recordingHandler) Enabled(_ context.Context, _ slog.Level) bool { return true }
|
||||
|
||||
func (h *recordingHandler) Handle(_ context.Context, r slog.Record) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.records = append(h.records, r.Clone())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *recordingHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h }
|
||||
func (h *recordingHandler) WithGroup(_ string) slog.Handler { return h }
|
||||
|
||||
func (h *recordingHandler) snapshot() []slog.Record {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
out := make([]slog.Record, len(h.records))
|
||||
copy(out, h.records)
|
||||
return out
|
||||
}
|
||||
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func mustParseURL(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", raw, err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// newRawTCPServer starts a TCP server on 127.0.0.1:0 that calls handle for
|
||||
// every accepted connection. It returns the listener address and a cleanup
|
||||
// function.
|
||||
func newRawTCPServer(t *testing.T, handle func(net.Conn)) (string, func()) {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go handle(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
return l.Addr().String(), func() { _ = l.Close() }
|
||||
}
|
||||
|
||||
// newProxyForTest returns a pinning proxy whose decide and dial functions
|
||||
// are set to test stubs. The proxy is started on a loopback ephemeral
|
||||
// port and stopped during test cleanup.
|
||||
func newProxyForTest(t *testing.T, p *pinningProxy) string {
|
||||
t.Helper()
|
||||
err := p.Start(testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("start pinning proxy: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = p.Stop(testLogger())
|
||||
})
|
||||
return p.URL()
|
||||
}
|
||||
|
||||
func TestPinningProxy_Forward_Pinned_Success(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Host != "example.com" {
|
||||
t.Errorf("upstream expected Host=example.com, got %q", r.Host)
|
||||
}
|
||||
_, _ = fmt.Fprint(w, "hello-from-upstream")
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
upstreamURL := mustParseURL(t, upstream.URL)
|
||||
|
||||
var decideCalls atomic.Int32
|
||||
p := newPinningProxy(nil, nil, false, false, 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, 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, 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, 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, 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, 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, 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, false)
|
||||
p.decide = stubDecide
|
||||
p.dialPinned = func(_ context.Context, network string, addrs []netip.Addr, _ string) (net.Conn, error) {
|
||||
if len(addrs) != 1 || addrs[0].String() != "93.184.216.34" {
|
||||
t.Errorf("dialPinned got addrs %v, want [93.184.216.34]", addrs)
|
||||
}
|
||||
return net.Dial(network, upstreamURL.Host)
|
||||
}
|
||||
proxyURL := newProxyForTest(t, p)
|
||||
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(mustParseURL(t, proxyURL)),
|
||||
},
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Get("http://rebind.example/")
|
||||
if err != nil {
|
||||
t.Fatalf("GET via proxy: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if string(body) != "public-upstream" {
|
||||
t.Fatalf("body = %q, want %q", body, "public-upstream")
|
||||
}
|
||||
if got := lookupCount.Load(); got != 1 {
|
||||
t.Fatalf("decide called %d times, want exactly 1 (rebind protection)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug verifies that
|
||||
// when decide fails because the request context was canceled or its
|
||||
// deadline expired (the canonical case is Chromium dropping a speculative
|
||||
// CONNECT before the proxy finishes resolving the host), the proxy logs
|
||||
// at debug and not at warn. Policy refusals must still warn; see
|
||||
// [TestPinningProxy_CONNECT_BlockedByDecide].
|
||||
func TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug(t *testing.T) {
|
||||
rec := &recordingHandler{}
|
||||
p := newPinningProxy(nil, nil, false, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
// Mimic the wrap chain produced by outbound.resolveHost when the
|
||||
// DNS lookup is canceled mid-flight by Chromium hanging up.
|
||||
return gotenberg.OutboundDecision{}, fmt.Errorf("validate '%s' host: resolve %q: lookup %s: %w", "https://www.google.com:443", "www.google.com", "www.google.com", context.Canceled)
|
||||
}
|
||||
|
||||
err := p.Start(slog.New(rec))
|
||||
if err != nil {
|
||||
t.Fatalf("start pinning proxy: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
|
||||
proxyURL := p.URL()
|
||||
|
||||
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("dial proxy: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
_, err = fmt.Fprintf(conn, "CONNECT www.google.com:443 HTTP/1.1\r\nHost: www.google.com:443\r\n\r\n")
|
||||
if err != nil {
|
||||
t.Fatalf("write CONNECT: %v", err)
|
||||
}
|
||||
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("read response: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
|
||||
records := rec.snapshot()
|
||||
var found bool
|
||||
for _, r := range records {
|
||||
if !strings.Contains(r.Message, "www.google.com:443") {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if r.Level != slog.LevelDebug {
|
||||
t.Fatalf("record level = %v, want Debug; message: %s", r.Level, r.Message)
|
||||
}
|
||||
if !strings.Contains(r.Message, "abandoned") {
|
||||
t.Fatalf("message = %q, want it to mention abandoned", r.Message)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected a log record mentioning www.google.com:443, found none")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPinningProxy_Forward_ClientCancellation_LoggedAtDebug is the
|
||||
// handleForward equivalent of
|
||||
// [TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug]. Plain
|
||||
// HTTP forward requests aborted by the client must also log at debug.
|
||||
func TestPinningProxy_Forward_ClientCancellation_LoggedAtDebug(t *testing.T) {
|
||||
rec := &recordingHandler{}
|
||||
p := newPinningProxy(nil, nil, false, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.OutboundDecision{}, fmt.Errorf("validate host: %w", context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
err := p.Start(slog.New(rec))
|
||||
if err != nil {
|
||||
t.Fatalf("start pinning proxy: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
|
||||
proxyURL := p.URL()
|
||||
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{Proxy: http.ProxyURL(mustParseURL(t, proxyURL))},
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
resp, err := client.Get("http://www.google.com/")
|
||||
if err != nil {
|
||||
t.Fatalf("GET via proxy: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
|
||||
records := rec.snapshot()
|
||||
var found bool
|
||||
for _, r := range records {
|
||||
if !strings.Contains(r.Message, "www.google.com") {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if r.Level != slog.LevelDebug {
|
||||
t.Fatalf("record level = %v, want Debug; message: %s", r.Level, r.Message)
|
||||
}
|
||||
if !strings.Contains(r.Message, "abandoned") {
|
||||
t.Fatalf("message = %q, want it to mention abandoned", r.Message)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected a log record mentioning www.google.com, found none")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPinningProxy_PolicyDenial_LoggedAtWarn protects the existing
|
||||
// behavior: a deny-list match (or any non-cancellation decide error) must
|
||||
// still surface at warn level so operators see real refusals.
|
||||
func TestPinningProxy_PolicyDenial_LoggedAtWarn(t *testing.T) {
|
||||
rec := &recordingHandler{}
|
||||
p := newPinningProxy(nil, nil, false, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.OutboundDecision{}, fmt.Errorf("denied: %w", gotenberg.ErrFiltered)
|
||||
}
|
||||
|
||||
err := p.Start(slog.New(rec))
|
||||
if err != nil {
|
||||
t.Fatalf("start pinning proxy: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
|
||||
proxyURL := p.URL()
|
||||
|
||||
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("dial proxy: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
_, err = fmt.Fprintf(conn, "CONNECT denied.example:443 HTTP/1.1\r\nHost: denied.example:443\r\n\r\n")
|
||||
if err != nil {
|
||||
t.Fatalf("write CONNECT: %v", err)
|
||||
}
|
||||
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("read response: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
records := rec.snapshot()
|
||||
var found bool
|
||||
for _, r := range records {
|
||||
if !strings.Contains(r.Message, "denied.example") {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if r.Level != slog.LevelWarn {
|
||||
t.Fatalf("record level = %v, want Warn; message: %s", r.Level, r.Message)
|
||||
}
|
||||
if !strings.Contains(r.Message, "blocked") {
|
||||
t.Fatalf("message = %q, want it to mention blocked", r.Message)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected a log record mentioning denied.example, found none")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPinningProxy_CONNECT_DialCancellation_LoggedAtDebug verifies that
|
||||
// when the upstream dial fails because the request context was canceled
|
||||
// (typically Chromium dropping a speculative CONNECT before a slow IPv6
|
||||
// dial completes), the proxy logs at debug rather than warn. Genuine
|
||||
// dial failures must still warn; see
|
||||
// [TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn].
|
||||
func TestPinningProxy_CONNECT_DialCancellation_LoggedAtDebug(t *testing.T) {
|
||||
rec := &recordingHandler{}
|
||||
p := newPinningProxy(nil, nil, false, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||
}
|
||||
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||
// Mimic a dial canceled mid-flight by the client hanging up,
|
||||
// which is what net.Dialer returns when ctx.Err() is Canceled.
|
||||
return nil, fmt.Errorf("dial tcp [2001:4860:482b:7700::]:443: %w", context.Canceled)
|
||||
}
|
||||
|
||||
err := p.Start(slog.New(rec))
|
||||
if err != nil {
|
||||
t.Fatalf("start pinning proxy: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
|
||||
proxyURL := p.URL()
|
||||
|
||||
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("dial proxy: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
_, err = fmt.Fprintf(conn, "CONNECT www.google.com:443 HTTP/1.1\r\nHost: www.google.com:443\r\n\r\n")
|
||||
if err != nil {
|
||||
t.Fatalf("write CONNECT: %v", err)
|
||||
}
|
||||
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("read response: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, want 502", resp.StatusCode)
|
||||
}
|
||||
|
||||
records := rec.snapshot()
|
||||
var found bool
|
||||
for _, r := range records {
|
||||
if !strings.Contains(r.Message, "CONNECT dial") || !strings.Contains(r.Message, "www.google.com:443") {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if r.Level != slog.LevelDebug {
|
||||
t.Fatalf("record level = %v, want Debug; message: %s", r.Level, r.Message)
|
||||
}
|
||||
if !strings.Contains(r.Message, "abandoned") {
|
||||
t.Fatalf("message = %q, want it to mention abandoned", r.Message)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected a log record mentioning CONNECT dial for www.google.com:443, found none")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn guards the existing
|
||||
// behavior: a genuine dial failure (host unreachable, refused, etc.)
|
||||
// must still warn so operators see real problems.
|
||||
func TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn(t *testing.T) {
|
||||
rec := &recordingHandler{}
|
||||
p := newPinningProxy(nil, nil, false, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||
}
|
||||
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||
return nil, errors.New("connection refused")
|
||||
}
|
||||
|
||||
err := p.Start(slog.New(rec))
|
||||
if err != nil {
|
||||
t.Fatalf("start pinning proxy: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
|
||||
proxyURL := p.URL()
|
||||
|
||||
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("dial proxy: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
_, err = fmt.Fprintf(conn, "CONNECT real.example:443 HTTP/1.1\r\nHost: real.example:443\r\n\r\n")
|
||||
if err != nil {
|
||||
t.Fatalf("write CONNECT: %v", err)
|
||||
}
|
||||
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("read response: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
records := rec.snapshot()
|
||||
var found bool
|
||||
for _, r := range records {
|
||||
if !strings.Contains(r.Message, "CONNECT dial") || !strings.Contains(r.Message, "real.example") {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if r.Level != slog.LevelWarn {
|
||||
t.Fatalf("record level = %v, want Warn; message: %s", r.Level, r.Message)
|
||||
}
|
||||
if !strings.Contains(r.Message, "failed") {
|
||||
t.Fatalf("message = %q, want it to mention failed", r.Message)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected a log record mentioning CONNECT dial for real.example, found none")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPinningProxy_Forward_RoundTripCancellation_LoggedAtDebug verifies
|
||||
// the handleForward dial-cancellation path: when the inner Transport's
|
||||
// dial returns a canceled error (client hung up mid-dial), the proxy
|
||||
// logs at debug, not warn. Genuine RoundTrip failures still warn.
|
||||
func TestPinningProxy_Forward_RoundTripCancellation_LoggedAtDebug(t *testing.T) {
|
||||
rec := &recordingHandler{}
|
||||
p := newPinningProxy(nil, nil, false, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||
}
|
||||
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||
return nil, fmt.Errorf("dial tcp [2001:4860::]:80: %w", context.Canceled)
|
||||
}
|
||||
|
||||
err := p.Start(slog.New(rec))
|
||||
if err != nil {
|
||||
t.Fatalf("start pinning proxy: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
|
||||
proxyURL := p.URL()
|
||||
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{Proxy: http.ProxyURL(mustParseURL(t, proxyURL))},
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
resp, err := client.Get("http://www.google.com/")
|
||||
if err != nil {
|
||||
t.Fatalf("GET via proxy: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, want 502", resp.StatusCode)
|
||||
}
|
||||
|
||||
records := rec.snapshot()
|
||||
var found bool
|
||||
for _, r := range records {
|
||||
if !strings.Contains(r.Message, "forward RoundTrip") || !strings.Contains(r.Message, "www.google.com") {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if r.Level != slog.LevelDebug {
|
||||
t.Fatalf("record level = %v, want Debug; message: %s", r.Level, r.Message)
|
||||
}
|
||||
if !strings.Contains(r.Message, "abandoned") {
|
||||
t.Fatalf("message = %q, want it to mention abandoned", r.Message)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected a log record mentioning forward RoundTrip for www.google.com, found none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsClientCancellation(t *testing.T) {
|
||||
canceledCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "wrapped context.Canceled",
|
||||
ctx: context.Background(),
|
||||
err: fmt.Errorf("validate host: %w", context.Canceled),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wrapped context.DeadlineExceeded",
|
||||
ctx: context.Background(),
|
||||
err: fmt.Errorf("validate host: %w", context.DeadlineExceeded),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "request context already done",
|
||||
ctx: canceledCtx,
|
||||
err: errors.New("some unrelated error"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "policy denial",
|
||||
ctx: context.Background(),
|
||||
err: fmt.Errorf("denied: %w", gotenberg.ErrFiltered),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "plain dial failure",
|
||||
ctx: context.Background(),
|
||||
err: errors.New("connection refused"),
|
||||
want: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := isClientCancellation(tc.ctx, tc.err)
|
||||
if got != tc.want {
|
||||
t.Fatalf("isClientCancellation = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinningProxy_StartTwice(t *testing.T) {
|
||||
p := newPinningProxy(nil, nil, false, 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, 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)
|
||||
}
|
||||
}
|
||||
76
pkg/modules/chromium/pinning_proxy_upstream_test.go
Normal file
76
pkg/modules/chromium/pinning_proxy_upstream_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
// TestPinningProxy_Forward_ThroughUpstreamProxy verifies that when the
|
||||
// operator opts into proxy-environment honoring, a plain HTTP request is
|
||||
// forwarded through the upstream (corporate) proxy with the credentials
|
||||
// Chromium cannot supply. See https://github.com/gotenberg/gotenberg/issues/1592.
|
||||
func TestPinningProxy_Forward_ThroughUpstreamProxy(t *testing.T) {
|
||||
var gotAuth atomic.Value
|
||||
gotAuth.Store("")
|
||||
|
||||
// Stand-in for the corporate proxy: an HTTP server that receives the
|
||||
// forwarded request and records the injected Proxy-Authorization.
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth.Store(r.Header.Get("Proxy-Authorization"))
|
||||
_, _ = fmt.Fprint(w, "via-corporate-proxy")
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
upstreamURL := mustParseURL(t, upstream.URL)
|
||||
upstreamURL.User = url.UserPassword("bob", "pw")
|
||||
|
||||
p := newPinningProxy(nil, nil, false, false, true)
|
||||
// Force every destination through our stub upstream proxy.
|
||||
p.upstreamProxy = func(_ *url.URL) (*url.URL, error) { return upstreamURL, nil }
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||
}
|
||||
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||
t.Fatal("dialPinned must not be called when routing through an upstream proxy")
|
||||
return nil, nil
|
||||
}
|
||||
proxyURL := newProxyForTest(t, p)
|
||||
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{Proxy: http.ProxyURL(mustParseURL(t, proxyURL))},
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Get("http://example.com/")
|
||||
if err != nil {
|
||||
t.Fatalf("GET via proxy: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
if string(body) != "via-corporate-proxy" {
|
||||
t.Fatalf("body = %q, want via-corporate-proxy", body)
|
||||
}
|
||||
|
||||
wantAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("bob:pw"))
|
||||
if got := gotAuth.Load().(string); got != wantAuth {
|
||||
t.Fatalf("upstream proxy saw Proxy-Authorization %q, want %q", got, wantAuth)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -202,6 +203,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
err = errors.Join(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile))
|
||||
continue
|
||||
}
|
||||
p.MatchTimeout = 5 * time.Second
|
||||
scopeRegexp = p
|
||||
}
|
||||
|
||||
@@ -339,11 +341,12 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
||||
defaultScreenshotOptions := DefaultScreenshotOptions()
|
||||
|
||||
var (
|
||||
width, height int
|
||||
clip bool
|
||||
format string
|
||||
quality int
|
||||
optimizeForSpeed bool
|
||||
width, height int
|
||||
clip bool
|
||||
format string
|
||||
quality int
|
||||
optimizeForSpeed bool
|
||||
deviceScaleFactor float64
|
||||
)
|
||||
|
||||
form.
|
||||
@@ -386,21 +389,51 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
||||
quality = intValue
|
||||
return nil
|
||||
}).
|
||||
Bool("optimizeForSpeed", &optimizeForSpeed, defaultScreenshotOptions.OptimizeForSpeed)
|
||||
Bool("optimizeForSpeed", &optimizeForSpeed, defaultScreenshotOptions.OptimizeForSpeed).
|
||||
Float64("deviceScaleFactor", &deviceScaleFactor, defaultScreenshotOptions.DeviceScaleFactor)
|
||||
|
||||
screenshotOptions := ScreenshotOptions{
|
||||
Options: options,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Clip: clip,
|
||||
Format: format,
|
||||
Quality: quality,
|
||||
OptimizeForSpeed: optimizeForSpeed,
|
||||
Options: options,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Clip: clip,
|
||||
Format: format,
|
||||
Quality: quality,
|
||||
OptimizeForSpeed: optimizeForSpeed,
|
||||
DeviceScaleFactor: deviceScaleFactor,
|
||||
}
|
||||
|
||||
return form, screenshotOptions
|
||||
}
|
||||
|
||||
// rejectFileScheme returns an HTTP 400 [api] error when rawURL uses the
|
||||
// file:// scheme. /forms/chromium/convert/url and
|
||||
// /forms/chromium/screenshot/url accept user-supplied URLs and are
|
||||
// intended for navigating to remote HTTP(S) resources; allowing file://
|
||||
// lets a caller reach Chromium's working directory through the default
|
||||
// deny-list's /tmp/ allowance, which exists only to serve main-page
|
||||
// HTML/Markdown that the other routes generate. Filter the scheme at the
|
||||
// route layer where no request-scoped allowedFilePrefixes exists.
|
||||
func rejectFileScheme(rawURL string) error {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("parse URL: %w", err),
|
||||
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid URL: %s", err)),
|
||||
)
|
||||
}
|
||||
if strings.EqualFold(parsed.Scheme, "file") {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("file:// scheme not allowed on URL route"),
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusBadRequest,
|
||||
"file:// URLs are not accepted on this route. Use the /convert/html or /convert/markdown routes to render local HTML",
|
||||
),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// convertUrlRoute returns an [api.Route] which can convert a URL to PDF.
|
||||
func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
return api.Route{
|
||||
@@ -413,13 +446,15 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
mode := pdfengines.FormDataPdfSplitMode(form, false)
|
||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
||||
encrypt := 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)
|
||||
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||
|
||||
var url string
|
||||
err := form.
|
||||
@@ -429,14 +464,21 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
|
||||
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
|
||||
watermark.Expression = watermarkFile
|
||||
}
|
||||
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
|
||||
stamp.Expression = stampFile
|
||||
err = rejectFileScheme(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reject URL scheme: %w", err)
|
||||
}
|
||||
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages)
|
||||
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, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert URL to PDF: %w", err)
|
||||
}
|
||||
@@ -465,6 +507,11 @@ func screenshotUrlRoute(chromium Api) api.Route {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
|
||||
err = rejectFileScheme(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reject URL scheme: %w", err)
|
||||
}
|
||||
|
||||
err = screenshotUrl(ctx, chromium, url, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("URL screenshot: %w", err)
|
||||
@@ -488,13 +535,15 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
mode := pdfengines.FormDataPdfSplitMode(form, false)
|
||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
||||
encrypt := 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)
|
||||
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||
|
||||
var inputPath string
|
||||
err := form.
|
||||
@@ -504,16 +553,18 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
|
||||
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
|
||||
watermark.Expression = watermarkFile
|
||||
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate watermark: %w", err)
|
||||
}
|
||||
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
|
||||
stamp.Expression = stampFile
|
||||
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, watermark, stamp, rotateAngle, rotatePages)
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert HTML to PDF: %w", err)
|
||||
}
|
||||
@@ -567,13 +618,15 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
mode := pdfengines.FormDataPdfSplitMode(form, false)
|
||||
pdfFormats := pdfengines.FormDataPdfFormats(form)
|
||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
|
||||
encrypt := 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)
|
||||
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||
|
||||
var (
|
||||
inputPath string
|
||||
@@ -588,11 +641,13 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
|
||||
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
|
||||
watermark.Expression = watermarkFile
|
||||
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate watermark: %w", err)
|
||||
}
|
||||
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
|
||||
stamp.Expression = stampFile
|
||||
err = pdfengines.EnsureStampFile(&stamp, stampFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate stamp: %w", err)
|
||||
}
|
||||
|
||||
url, err := markdownToHtml(ctx, inputPath, markdownPaths)
|
||||
@@ -601,7 +656,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
}
|
||||
|
||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages)
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert markdown to PDF: %w", err)
|
||||
}
|
||||
@@ -726,7 +781,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, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error {
|
||||
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, encrypt gotenberg.EncryptOptions, embedPaths []string, embedsMetadata map[string]map[string]string, facturX gotenberg.FacturX, facturxXmlPath string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error {
|
||||
outputPath := ctx.GeneratePath(".pdf")
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1130.
|
||||
filename := ctx.OutputFilename(outputPath)
|
||||
@@ -788,7 +843,17 @@ 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)
|
||||
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = pdfengines.ValidatePdfEncryptCompat(encrypt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = pdfengines.ValidateFacturXCompat(facturX, facturxXmlPath, pdfFormats)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -813,6 +878,8 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
||||
return fmt.Errorf("rotate PDFs: %w", err)
|
||||
}
|
||||
|
||||
pdfFormats = pdfengines.FacturXPdfFormats(ctx, engine, facturX, pdfFormats, true, nil)
|
||||
|
||||
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert PDF(s): %w", err)
|
||||
@@ -830,7 +897,17 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
||||
return fmt.Errorf("embed files into PDFs: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
|
||||
err = pdfengines.EmbedFilesMetadataStub(ctx, engine, embedsMetadata, convertOutputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set embeds metadata: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.ApplyFacturXStub(ctx, engine, facturX, facturxXmlPath, convertOutputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply Factur-X: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.EncryptPdfStub(ctx, engine, encrypt, convertOutputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||
}
|
||||
|
||||
@@ -14,104 +14,169 @@ import (
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/chromedp"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func printToPdfActionFunc(logger *slog.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc {
|
||||
// resolvePdfOptions applies the cross-option constraints Chromium imposes
|
||||
// before printing.
|
||||
//
|
||||
// Chromium derives the PDF document outline from the tagged-PDF structure
|
||||
// tree, so [PdfOptions.GenerateDocumentOutline] produces no outline unless
|
||||
// tagged PDF is also generated. Requesting an outline therefore implies
|
||||
// tagged PDF. See https://github.com/gotenberg/gotenberg/issues/1579.
|
||||
func resolvePdfOptions(options PdfOptions) PdfOptions {
|
||||
if options.GenerateDocumentOutline {
|
||||
options.GenerateTaggedPdf = true
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
paperHeight := options.PaperHeight
|
||||
pageRanges := options.PageRanges
|
||||
|
||||
if options.SinglePage {
|
||||
logger.DebugContext(ctx, "single page PDF")
|
||||
|
||||
_, _, _, _, _, cssContentSize, err := page.GetLayoutMetrics().Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get layout metrics: %w", err)
|
||||
}
|
||||
|
||||
// There are 96 CSS pixels per inch.
|
||||
// See https://issues.chromium.org/issues/40267771#comment14.
|
||||
// We add top and bottom margins so that the content area
|
||||
// is large enough to fit the entire content.
|
||||
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
|
||||
pageRanges = "1" // little dirty hack to avoid leftovers.
|
||||
if options.GenerateDocumentOutline && !options.GenerateTaggedPdf {
|
||||
logger.DebugContext(ctx, "document outline requested, enabling tagged PDF because Chromium derives the outline from the structure tree")
|
||||
}
|
||||
|
||||
printToPdf := page.PrintToPDF().
|
||||
WithTransferMode(page.PrintToPDFTransferModeReturnAsStream).
|
||||
WithLandscape(options.Landscape).
|
||||
WithPrintBackground(options.PrintBackground).
|
||||
WithScale(options.Scale).
|
||||
WithPaperWidth(options.PaperWidth).
|
||||
WithPaperHeight(paperHeight).
|
||||
WithMarginTop(options.MarginTop).
|
||||
WithMarginBottom(options.MarginBottom).
|
||||
WithMarginLeft(options.MarginLeft).
|
||||
WithMarginRight(options.MarginRight).
|
||||
WithPageRanges(pageRanges).
|
||||
WithPreferCSSPageSize(options.PreferCssPageSize).
|
||||
WithGenerateDocumentOutline(options.GenerateDocumentOutline).
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1210.
|
||||
WithGenerateTaggedPDF(options.GenerateTaggedPdf)
|
||||
options = resolvePdfOptions(options)
|
||||
|
||||
hasCustomHeaderFooter := options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate ||
|
||||
options.FooterTemplate != DefaultPdfOptions().FooterTemplate
|
||||
// ctx is the chromedp task context, derived from context.Background(),
|
||||
// so the span is started under reqCtx to keep print_to_pdf in the
|
||||
// conversion trace instead of orphaning it into a new one.
|
||||
_, span := gotenberg.Tracer().Start(reqCtx, "chromium.print_to_pdf",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(printToPdfAttrs(options)...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
if !hasCustomHeaderFooter {
|
||||
logger.DebugContext(ctx, "no custom header nor footer")
|
||||
err := func() error {
|
||||
paperHeight := options.PaperHeight
|
||||
pageRanges := options.PageRanges
|
||||
|
||||
printToPdf = printToPdf.WithDisplayHeaderFooter(false)
|
||||
if options.SinglePage {
|
||||
logger.DebugContext(ctx, "single page PDF")
|
||||
|
||||
_, _, _, _, _, cssContentSize, err := page.GetLayoutMetrics().Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get layout metrics: %w", err)
|
||||
}
|
||||
|
||||
// There are 96 CSS pixels per inch.
|
||||
// See https://issues.chromium.org/issues/40267771#comment14.
|
||||
// We add top and bottom margins so that the content area
|
||||
// is large enough to fit the entire content.
|
||||
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
|
||||
pageRanges = "1" // little dirty hack to avoid leftovers.
|
||||
}
|
||||
|
||||
printToPdf := page.PrintToPDF().
|
||||
WithTransferMode(page.PrintToPDFTransferModeReturnAsStream).
|
||||
WithLandscape(options.Landscape).
|
||||
WithPrintBackground(options.PrintBackground).
|
||||
WithScale(options.Scale).
|
||||
WithPaperWidth(options.PaperWidth).
|
||||
WithPaperHeight(paperHeight).
|
||||
WithMarginTop(options.MarginTop).
|
||||
WithMarginBottom(options.MarginBottom).
|
||||
WithMarginLeft(options.MarginLeft).
|
||||
WithMarginRight(options.MarginRight).
|
||||
WithPageRanges(pageRanges).
|
||||
WithPreferCSSPageSize(options.PreferCssPageSize).
|
||||
WithGenerateDocumentOutline(options.GenerateDocumentOutline).
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1210.
|
||||
WithGenerateTaggedPDF(options.GenerateTaggedPdf)
|
||||
|
||||
hasCustomHeaderFooter := options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate ||
|
||||
options.FooterTemplate != DefaultPdfOptions().FooterTemplate
|
||||
|
||||
if !hasCustomHeaderFooter {
|
||||
logger.DebugContext(ctx, "no custom header nor footer")
|
||||
|
||||
printToPdf = printToPdf.WithDisplayHeaderFooter(false)
|
||||
} else {
|
||||
logger.DebugContext(ctx, "with custom header and/or footer")
|
||||
|
||||
printToPdf = printToPdf.
|
||||
WithDisplayHeaderFooter(true).
|
||||
WithHeaderTemplate(options.HeaderTemplate).
|
||||
WithFooterTemplate(options.FooterTemplate)
|
||||
}
|
||||
|
||||
logger.DebugContext(ctx, fmt.Sprintf("print to PDF with: %+v", printToPdf))
|
||||
|
||||
_, stream, err := printToPdf.Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("print to PDF: %w", err)
|
||||
}
|
||||
|
||||
reader := &streamReader{
|
||||
ctx: ctx,
|
||||
handle: stream,
|
||||
r: nil,
|
||||
pos: 0,
|
||||
eof: false,
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err = reader.Close()
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, fmt.Sprintf("close reader: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open output path: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err = file.Close()
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, fmt.Sprintf("close output path: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
buffer := bufio.NewReader(reader)
|
||||
|
||||
_, err = buffer.WriteTo(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write result to output path: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
} else {
|
||||
logger.DebugContext(ctx, "with custom header and/or footer")
|
||||
|
||||
printToPdf = printToPdf.
|
||||
WithDisplayHeaderFooter(true).
|
||||
WithHeaderTemplate(options.HeaderTemplate).
|
||||
WithFooterTemplate(options.FooterTemplate)
|
||||
span.SetStatus(codes.Ok, "")
|
||||
}
|
||||
|
||||
logger.DebugContext(ctx, fmt.Sprintf("print to PDF with: %+v", printToPdf))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, stream, err := printToPdf.Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("print to PDF: %w", err)
|
||||
}
|
||||
|
||||
reader := &streamReader{
|
||||
ctx: ctx,
|
||||
handle: stream,
|
||||
r: nil,
|
||||
pos: 0,
|
||||
eof: false,
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err = reader.Close()
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, fmt.Sprintf("close reader: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open output path: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err = file.Close()
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, fmt.Sprintf("close output path: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
buffer := bufio.NewReader(reader)
|
||||
|
||||
_, err = buffer.WriteTo(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write result to output path: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
// printToPdfAttrs derives bounded, low-cardinality attributes from the print
|
||||
// options. Raw header/footer templates and page ranges are reduced to booleans
|
||||
// to avoid leaking document content and exploding cardinality.
|
||||
func printToPdfAttrs(options PdfOptions) []attribute.KeyValue {
|
||||
return []attribute.KeyValue{
|
||||
attribute.Bool("gotenberg.chromium.print.landscape", options.Landscape),
|
||||
attribute.Bool("gotenberg.chromium.print.print_background", options.PrintBackground),
|
||||
attribute.Float64("gotenberg.chromium.print.scale", options.Scale),
|
||||
attribute.Float64("gotenberg.chromium.print.paper_width", options.PaperWidth),
|
||||
attribute.Float64("gotenberg.chromium.print.paper_height", options.PaperHeight),
|
||||
attribute.Bool("gotenberg.chromium.print.single_page", options.SinglePage),
|
||||
attribute.Bool("gotenberg.chromium.print.prefer_css_page_size", options.PreferCssPageSize),
|
||||
attribute.Bool("gotenberg.chromium.print.generate_tagged_pdf", options.GenerateTaggedPdf),
|
||||
attribute.Bool("gotenberg.chromium.print.has_page_ranges", options.PageRanges != ""),
|
||||
attribute.Bool("gotenberg.chromium.print.has_header", options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate),
|
||||
attribute.Bool("gotenberg.chromium.print.has_footer", options.FooterTemplate != DefaultPdfOptions().FooterTemplate),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,11 +229,11 @@ func captureScreenshotActionFunc(logger *slog.Logger, outputPath string, options
|
||||
}
|
||||
}
|
||||
|
||||
func setDeviceMetricsOverride(logger *slog.Logger, width, height int) chromedp.ActionFunc {
|
||||
func setDeviceMetricsOverride(logger *slog.Logger, width, height int, deviceScaleFactor float64) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
logger.DebugContext(ctx, "set device metrics override")
|
||||
|
||||
err := emulation.SetDeviceMetricsOverride(int64(width), int64(height), 1.0, false).Do(ctx)
|
||||
err := emulation.SetDeviceMetricsOverride(int64(width), int64(height), deviceScaleFactor, false).Do(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -332,11 +397,13 @@ func navigateActionFunc(logger *slog.Logger, url string, skipNetworkIdleEvent, s
|
||||
return func(ctx context.Context) error {
|
||||
logger.DebugContext(ctx, fmt.Sprintf("navigate to '%s'", url))
|
||||
|
||||
_, _, _, _, err := page.Navigate(url).Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("navigate to '%s': %w", url, err)
|
||||
}
|
||||
|
||||
// Register lifecycle listeners before issuing Page.navigate. For
|
||||
// fast loads (typically file:// pages with no external
|
||||
// sub-resources), DomContentEventFired / LoadEventFired /
|
||||
// LoadingFinished can fire between Navigate.Do returning and
|
||||
// runBatch spawning the waiter goroutines. Registering ahead of
|
||||
// the navigate command closes that race.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1561.
|
||||
waitFunc := []func() error{
|
||||
waitForEventDomContentEventFired(ctx, logger),
|
||||
waitForEventLoadEventFired(ctx, logger),
|
||||
@@ -355,6 +422,11 @@ func navigateActionFunc(logger *slog.Logger, url string, skipNetworkIdleEvent, s
|
||||
logger.DebugContext(ctx, "skipping network almost idle event")
|
||||
}
|
||||
|
||||
_, _, _, _, err := page.Navigate(url).Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("navigate to '%s': %w", url, err)
|
||||
}
|
||||
|
||||
err = runBatch(
|
||||
ctx,
|
||||
waitFunc...,
|
||||
|
||||
52
pkg/modules/chromium/tasks_test.go
Normal file
52
pkg/modules/chromium/tasks_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package chromium
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolvePdfOptions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
generateOutline bool
|
||||
generateTaggedIn bool
|
||||
generateTaggedWant bool
|
||||
}{
|
||||
{
|
||||
scenario: "outline requested forces tagged PDF",
|
||||
generateOutline: true,
|
||||
generateTaggedIn: false,
|
||||
generateTaggedWant: true,
|
||||
},
|
||||
{
|
||||
scenario: "outline requested keeps tagged PDF on",
|
||||
generateOutline: true,
|
||||
generateTaggedIn: true,
|
||||
generateTaggedWant: true,
|
||||
},
|
||||
{
|
||||
scenario: "no outline leaves tagged PDF off",
|
||||
generateOutline: false,
|
||||
generateTaggedIn: false,
|
||||
generateTaggedWant: false,
|
||||
},
|
||||
{
|
||||
scenario: "no outline keeps tagged PDF on",
|
||||
generateOutline: false,
|
||||
generateTaggedIn: true,
|
||||
generateTaggedWant: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
options := DefaultPdfOptions()
|
||||
options.GenerateDocumentOutline = tc.generateOutline
|
||||
options.GenerateTaggedPdf = tc.generateTaggedIn
|
||||
|
||||
got := resolvePdfOptions(options)
|
||||
|
||||
if got.GenerateTaggedPdf != tc.generateTaggedWant {
|
||||
t.Errorf("expected GenerateTaggedPdf=%t, got %t", tc.generateTaggedWant, got.GenerateTaggedPdf)
|
||||
}
|
||||
if got.GenerateDocumentOutline != tc.generateOutline {
|
||||
t.Errorf("expected GenerateDocumentOutline=%t, got %t", tc.generateOutline, got.GenerateDocumentOutline)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
37
pkg/modules/chromium/version_test.go
Normal file
37
pkg/modules/chromium/version_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestChromiumDetectVersion(t *testing.T) {
|
||||
t.Run("prefers the build-time version without executing Chromium", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "chromium"), []byte("Chromium 146.0.7680.80\n"), 0o600); err != nil {
|
||||
t.Fatalf("write version file: %v", err)
|
||||
}
|
||||
t.Setenv(gotenberg.BuildVersionsDirPathEnvVar, dir)
|
||||
|
||||
// A bogus binPath would error if executed, so a correct result proves
|
||||
// the build-time file is used instead of running Chromium.
|
||||
mod := &Chromium{args: browserArguments{binPath: "/nonexistent/chromium"}}
|
||||
if got := mod.Debug()["version"]; got != "Chromium 146.0.7680.80" {
|
||||
t.Errorf("Debug()[version] = %v, want the build-time value", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("falls back to executing Chromium when no build-time version", func(t *testing.T) {
|
||||
t.Setenv(gotenberg.BuildVersionsDirPathEnvVar, "")
|
||||
|
||||
// With no build-time file and a bogus binPath, the exec fallback runs
|
||||
// and records its error rather than a build-time value.
|
||||
mod := &Chromium{args: browserArguments{binPath: "/nonexistent/chromium"}}
|
||||
if got := mod.Debug()["version"]; got == "Chromium 146.0.7680.80" {
|
||||
t.Errorf("Debug()[version] = %v, expected the exec fallback", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
// Package exiftool provides an implementation of the gotenberg.PdfEngine
|
||||
// interface using the ExifTool command-line tool. This package allows for:
|
||||
// Package exiftool implements gotenberg.PdfEngine using the ExifTool command-line tool. Reads and writes PDF metadata.
|
||||
//
|
||||
// 1. The reading of metadata.
|
||||
// 2. The writing of metadata.
|
||||
// Requires the EXIFTOOL_BIN_PATH environment variable.
|
||||
//
|
||||
// The path to the exiftool binary must be specified using the
|
||||
// EXIFTOOL_BIN_PATH environment variable.
|
||||
//
|
||||
// See: https://exiftool.org.
|
||||
// See https://exiftool.org.
|
||||
package exiftool
|
||||
|
||||
@@ -2,16 +2,18 @@ package exiftool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/barasher/go-exiftool"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
@@ -23,10 +25,145 @@ 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 {
|
||||
binPath string
|
||||
|
||||
version string
|
||||
versionOnce sync.Once
|
||||
}
|
||||
|
||||
// Descriptor returns [ExifTool]'s module descriptor.
|
||||
@@ -61,26 +198,53 @@ func (engine *ExifTool) Validate() error {
|
||||
|
||||
// Debug returns additional debug data.
|
||||
func (engine *ExifTool) Debug() map[string]any {
|
||||
debug := make(map[string]any)
|
||||
return map[string]any{"version": engine.detectVersion()}
|
||||
}
|
||||
|
||||
cmd := exec.Command(engine.binPath, "-ver") //nolint:gosec
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
// detectVersion resolves the ExifTool version once, preferring the value
|
||||
// captured at image build time so it never spawns ExifTool at runtime. It falls
|
||||
// back to running exiftool -ver for local or non-Docker builds.
|
||||
func (engine *ExifTool) detectVersion() string {
|
||||
engine.versionOnce.Do(func() {
|
||||
if v, ok := gotenberg.BuildVersion("exiftool"); ok {
|
||||
engine.version = v
|
||||
return
|
||||
}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
debug["version"] = err.Error()
|
||||
return debug
|
||||
cmd := exec.Command(engine.binPath, "-ver") //nolint:gosec
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
engine.version = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
engine.version = strings.TrimSpace(string(output))
|
||||
})
|
||||
|
||||
return engine.version
|
||||
}
|
||||
|
||||
// spanAttrs returns the client-span attributes for an ExifTool invocation: the
|
||||
// server address and the ExifTool version, plus any extra attributes. The
|
||||
// version rides on every span so a trace records which ExifTool ran the
|
||||
// operation.
|
||||
func (engine *ExifTool) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue {
|
||||
attrs := make([]attribute.KeyValue, 0, 2+len(extra))
|
||||
attrs = append(attrs, semconv.ServerAddress(engine.binPath))
|
||||
if v := engine.detectVersion(); v != "" {
|
||||
attrs = append(attrs, attribute.String("gotenberg.exiftool.version", v))
|
||||
}
|
||||
|
||||
debug["version"] = strings.TrimSpace(string(output))
|
||||
return debug
|
||||
return append(attrs, extra...)
|
||||
}
|
||||
|
||||
// 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)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -94,7 +258,7 @@ func (engine *ExifTool) Merge(ctx context.Context, logger *slog.Logger, inputPat
|
||||
func (engine *ExifTool) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Split",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -108,7 +272,7 @@ func (engine *ExifTool) Split(ctx context.Context, logger *slog.Logger, mode got
|
||||
func (engine *ExifTool) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Flatten",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -122,7 +286,7 @@ func (engine *ExifTool) Flatten(ctx context.Context, logger *slog.Logger, inputP
|
||||
func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Convert",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -132,153 +296,100 @@ func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, format
|
||||
return err
|
||||
}
|
||||
|
||||
// ReadMetadata extracts the metadata of a given PDF file.
|
||||
// ReadMetadata extracts the metadata of a given PDF file by invoking
|
||||
// the exiftool binary with "-j" (JSON output) and parsing the result.
|
||||
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.ReadMetadata",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
|
||||
cmd := exec.CommandContext(ctx, engine.binPath, "-j", inputPath) //nolint:gosec
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
err = fmt.Errorf("new ExifTool: %w", err)
|
||||
err = fmt.Errorf("read metadata with ExifTool: %w", err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return nil, 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)
|
||||
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
|
||||
}
|
||||
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return fileMetadata[0].Fields, nil
|
||||
}
|
||||
metadata := files[0]
|
||||
|
||||
// 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.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
|
||||
if err != nil {
|
||||
err = fmt.Errorf("new ExifTool: %w", err)
|
||||
// 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 err
|
||||
return nil, 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)
|
||||
}
|
||||
|
||||
// Filter user-supplied metadata to prevent ExifTool pseudo-tags from
|
||||
// triggering dangerous side effects like file renames or moves.
|
||||
// See https://exiftool.org/TagNames/Extra.html.
|
||||
dangerousTags := []string{
|
||||
"FileName", // Writing this triggers a file rename in ExifTool
|
||||
"Directory", // Writing this triggers a file move in ExifTool
|
||||
}
|
||||
for _, tag := range dangerousTags {
|
||||
for _, tag := range systemTags {
|
||||
delete(metadata, 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 err
|
||||
}
|
||||
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(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
extraArgs, err := buildExifToolWriteArgs(metadata)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
exifTool.WriteMetadata(fileMetadata)
|
||||
if fileMetadata[0].Err != nil {
|
||||
err = fmt.Errorf("write PDF metadata with ExifTool: %w", fileMetadata[0].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
|
||||
@@ -292,7 +403,7 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *slog.Logger,
|
||||
func (engine *ExifTool) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.PageCount",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -345,7 +456,7 @@ func (engine *ExifTool) PageCount(ctx context.Context, logger *slog.Logger, inpu
|
||||
func (engine *ExifTool) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.WriteBookmarks",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -359,7 +470,7 @@ func (engine *ExifTool) WriteBookmarks(ctx context.Context, logger *slog.Logger,
|
||||
func (engine *ExifTool) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.ReadBookmarks",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -370,10 +481,10 @@ func (engine *ExifTool) ReadBookmarks(ctx context.Context, logger *slog.Logger,
|
||||
}
|
||||
|
||||
// Encrypt is not available in this implementation.
|
||||
func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
|
||||
func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Encrypt",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -387,7 +498,7 @@ func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputP
|
||||
func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.EmbedFiles",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -401,7 +512,7 @@ func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *slog.Logger, fil
|
||||
func (engine *ExifTool) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Watermark",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -415,7 +526,7 @@ func (engine *ExifTool) Watermark(ctx context.Context, logger *slog.Logger, inpu
|
||||
func (engine *ExifTool) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Stamp",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -429,7 +540,7 @@ func (engine *ExifTool) Stamp(ctx context.Context, logger *slog.Logger, inputPat
|
||||
func (engine *ExifTool) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Rotate",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
@@ -439,6 +550,21 @@ func (engine *ExifTool) Rotate(ctx context.Context, logger *slog.Logger, inputPa
|
||||
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)
|
||||
}
|
||||
|
||||
// InjectFacturXXMP is not available in this implementation.
|
||||
func (engine *ExifTool) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error {
|
||||
return fmt.Errorf("inject Factur-X XMP with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// ReadPdfAConformance is not available in this implementation.
|
||||
func (engine *ExifTool) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
|
||||
return "", "", fmt.Errorf("read PDF/A conformance with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*ExifTool)(nil)
|
||||
|
||||
213
pkg/modules/exiftool/exiftool_test.go
Normal file
213
pkg/modules/exiftool/exiftool_test.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package exiftool
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestBuildExifToolWriteArgs_String(t *testing.T) {
|
||||
args, err := buildExifToolWriteArgs(map[string]any{"Title": "sample"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := []string{"-Title=sample"}
|
||||
if !slices.Equal(args, want) {
|
||||
t.Fatalf("args = %v, want %v", args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExifToolWriteArgs_StringSlice(t *testing.T) {
|
||||
args, err := buildExifToolWriteArgs(map[string]any{"Keywords": []string{"first", "second"}})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := []string{"-Keywords=first", "-Keywords=second"}
|
||||
if !slices.Equal(args, want) {
|
||||
t.Fatalf("args = %v, want %v", args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExifToolWriteArgs_AnySliceOfStrings(t *testing.T) {
|
||||
args, err := buildExifToolWriteArgs(map[string]any{"Keywords": []any{"a", "b"}})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := []string{"-Keywords=a", "-Keywords=b"}
|
||||
if !slices.Equal(args, want) {
|
||||
t.Fatalf("args = %v, want %v", args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExifToolWriteArgs_AnySliceMixedRejected(t *testing.T) {
|
||||
_, err := buildExifToolWriteArgs(map[string]any{"Keywords": []any{"a", 42}})
|
||||
if !errors.Is(err, gotenberg.ErrPdfEngineMetadataValueNotSupported) {
|
||||
t.Fatalf("expected ErrPdfEngineMetadataValueNotSupported, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExifToolWriteArgs_Numbers(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
in any
|
||||
want string
|
||||
}{
|
||||
{"int", 42, "-K=42"},
|
||||
{"int64", int64(42), "-K=42"},
|
||||
{"float32", float32(1.5), "-K=1.5"},
|
||||
{"float64", 1.7, "-K=1.7"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
args, err := buildExifToolWriteArgs(map[string]any{"K": tc.in})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(args) != 1 || args[0] != tc.want {
|
||||
t.Fatalf("args = %v, want [%q]", args, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExifToolWriteArgs_Bool(t *testing.T) {
|
||||
args, err := buildExifToolWriteArgs(map[string]any{"Marked": true})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := []string{"-Marked=true"}
|
||||
if !slices.Equal(args, want) {
|
||||
t.Fatalf("args = %v, want %v", args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExifToolWriteArgs_InvalidKey(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"", // empty
|
||||
"-rm", // leading dash — would be parsed as a flag
|
||||
"foo\nbar", // newline
|
||||
"foo bar", // space
|
||||
"foo=bar", // contains equals
|
||||
"weird/char", // slash
|
||||
} {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
_, err := buildExifToolWriteArgs(map[string]any{key: "value"})
|
||||
if !errors.Is(err, gotenberg.ErrPdfEngineMetadataValueNotSupported) {
|
||||
t.Fatalf("expected ErrPdfEngineMetadataValueNotSupported for key %q, got %v", key, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExifToolWriteArgs_ControlCharValue(t *testing.T) {
|
||||
for _, val := range []string{
|
||||
"foo\nbar",
|
||||
"foo\rbar",
|
||||
"foo\x00bar",
|
||||
} {
|
||||
t.Run(val, func(t *testing.T) {
|
||||
_, err := buildExifToolWriteArgs(map[string]any{"Title": val})
|
||||
if !errors.Is(err, gotenberg.ErrPdfEngineMetadataValueNotSupported) {
|
||||
t.Fatalf("expected ErrPdfEngineMetadataValueNotSupported for value %q, got %v", val, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExifToolWriteArgs_DangerousTagsStripped(t *testing.T) {
|
||||
// Dangerous tag keys are silently dropped; legitimate keys still pass.
|
||||
args, err := buildExifToolWriteArgs(map[string]any{
|
||||
"Author": "legit",
|
||||
"FileName": "stolen.pdf",
|
||||
"System:FileName": "stolen.pdf",
|
||||
"Directory": "/tmp",
|
||||
"HardLink": "/tmp/link",
|
||||
"SymLink": "/tmp/link",
|
||||
"FilePermissions": "777",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !slices.Equal(args, []string{"-Author=legit"}) {
|
||||
t.Fatalf("args = %v, want [-Author=legit]", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExifToolWriteArgs_DangerousTagsCaseInsensitive(t *testing.T) {
|
||||
// Case variations are all dropped because exiftool is case-insensitive.
|
||||
args, err := buildExifToolWriteArgs(map[string]any{
|
||||
"filename": "x",
|
||||
"FILENAME": "x",
|
||||
"System:Filename": "x",
|
||||
"Title": "keep",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !slices.Equal(args, []string{"-Title=keep"}) {
|
||||
t.Fatalf("args = %v, want [-Title=keep]", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExifToolWriteArgs_UnsupportedType(t *testing.T) {
|
||||
_, err := buildExifToolWriteArgs(map[string]any{"K": map[string]any{"nested": "x"}})
|
||||
if !errors.Is(err, gotenberg.ErrPdfEngineMetadataValueNotSupported) {
|
||||
t.Fatalf("expected ErrPdfEngineMetadataValueNotSupported, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExifToolWriteArgs_Empty(t *testing.T) {
|
||||
args, err := buildExifToolWriteArgs(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(args) != 0 {
|
||||
t.Fatalf("args = %v, want empty", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDangerousTag(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
key string
|
||||
want bool
|
||||
}{
|
||||
{"FileName", true},
|
||||
{"filename", true},
|
||||
{"System:FileName", true},
|
||||
{"XMP:FileName", true},
|
||||
{"Directory", true},
|
||||
{"HardLink", true},
|
||||
{"SymLink", true},
|
||||
{"FilePermissions", true},
|
||||
{"Title", false},
|
||||
{"Author", false},
|
||||
{"FileNameExtra", false}, // Suffix must not match.
|
||||
{"", false},
|
||||
} {
|
||||
t.Run(tc.key, func(t *testing.T) {
|
||||
if got := isDangerousTag(tc.key); got != tc.want {
|
||||
t.Fatalf("isDangerousTag(%q) = %v, want %v", tc.key, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeKeyPattern(t *testing.T) {
|
||||
// Rejects leading dash to prevent argv-level flag injection.
|
||||
if safeKeyPattern.MatchString("-injected") {
|
||||
t.Fatalf("leading-dash key must be rejected")
|
||||
}
|
||||
// Accepts common legitimate forms.
|
||||
for _, k := range []string{"Title", "System:Title", "XMP-pdf:Title", "My_Tag.1"} {
|
||||
if !safeKeyPattern.MatchString(k) {
|
||||
t.Fatalf("key %q must be accepted", k)
|
||||
}
|
||||
}
|
||||
// Rejects control characters.
|
||||
for _, k := range []string{"a\nb", "a\rb", "a\x00b", "a b"} {
|
||||
if safeKeyPattern.MatchString(k) {
|
||||
t.Fatalf("control-char key %q must be rejected", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -36,7 +37,7 @@ var (
|
||||
ErrUnoException = errors.New("uno exception")
|
||||
|
||||
// ErrRuntimeException happens when unoconverter returns exit code 6.
|
||||
ErrRuntimeException = errors.New("uno exception")
|
||||
ErrRuntimeException = errors.New("runtime exception")
|
||||
|
||||
// ErrCoreDumped happens randomly; sometimes a conversion will work as
|
||||
// expected, and some other time the same conversion will fail.
|
||||
@@ -53,11 +54,15 @@ type Api struct {
|
||||
libreOffice libreOffice
|
||||
supervisor gotenberg.ProcessSupervisor
|
||||
|
||||
version string
|
||||
versionOnce sync.Once
|
||||
|
||||
reqsCounter metric.Int64Counter
|
||||
errsCounter metric.Int64Counter
|
||||
conversionDurationCounter metric.Float64Histogram
|
||||
queueWaitDurationCounter metric.Float64Histogram
|
||||
pdfOutputSizeCounter metric.Int64Histogram
|
||||
coreDumpedRetriesCounter metric.Int64Counter
|
||||
}
|
||||
|
||||
// Options gathers available options when converting a document to PDF.
|
||||
@@ -327,6 +332,11 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
||||
fs.Duration("libreoffice-idle-shutdown-timeout", 0, "Shutdown LibreOffice after being idle for the given duration. Set to 0 to disable this feature")
|
||||
fs.Bool("libreoffice-auto-start", false, "Automatically launch LibreOffice upon initialization if set to true; otherwise, LibreOffice will start at the time of the first conversion")
|
||||
fs.Duration("libreoffice-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for LibreOffice to start or restart")
|
||||
fs.StringSlice("libreoffice-allow-list", []string{}, "Set the allowed URLs for LibreOffice outbound fetches (embedded images, linked content) using regular expressions - supports multiple values")
|
||||
fs.StringSlice("libreoffice-deny-list", []string{}, "Set the denied URLs for LibreOffice outbound fetches using regular expressions - supports multiple values")
|
||||
fs.Bool("libreoffice-deny-private-ips", false, "Reject LibreOffice outbound URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted documents to mitigate SSRF against internal services")
|
||||
fs.Bool("libreoffice-deny-public-ips", false, "Reject LibreOffice outbound URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent outbound traffic from leaving a private network")
|
||||
fs.Bool("libreoffice-enable-environment-proxy", false, "Route LibreOffice outbound fetches through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials")
|
||||
|
||||
return fs
|
||||
}(),
|
||||
@@ -353,6 +363,13 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
||||
binPath: libreOfficeBinPath,
|
||||
unoBinPath: unoBinPath,
|
||||
startTimeout: flags.MustDuration("libreoffice-start-timeout"),
|
||||
proxyOptions: outboundProxyOptions{
|
||||
allowList: flags.MustRegexpSlice("libreoffice-allow-list"),
|
||||
denyList: flags.MustRegexpSlice("libreoffice-deny-list"),
|
||||
denyPrivateIPs: flags.MustBool("libreoffice-deny-private-ips"),
|
||||
denyPublicIPs: flags.MustBool("libreoffice-deny-public-ips"),
|
||||
enableEnvironmentProxy: flags.MustBool("libreoffice-enable-environment-proxy"),
|
||||
},
|
||||
}
|
||||
|
||||
// Logger.
|
||||
@@ -360,7 +377,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
||||
|
||||
// Process.
|
||||
a.libreOffice = newLibreOfficeProcess(a.args)
|
||||
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"), 1, flags.MustDuration("libreoffice-idle-shutdown-timeout"))
|
||||
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, "libreoffice", a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"), 1, flags.MustDuration("libreoffice-idle-shutdown-timeout"))
|
||||
|
||||
// Metrics.
|
||||
meter := gotenberg.Meter()
|
||||
@@ -458,6 +475,15 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
||||
return fmt.Errorf("create libreoffice.pdf.output.size histogram: %w", err)
|
||||
}
|
||||
|
||||
a.coreDumpedRetriesCounter, err = meter.Int64Counter(
|
||||
"libreoffice.conversion.retries.total",
|
||||
metric.WithDescription("Total number of LibreOffice conversion retries after a core dump"),
|
||||
metric.WithUnit("{retry}"),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create libreoffice.conversion.retries.total counter: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -467,12 +493,12 @@ func (a *Api) Validate() error {
|
||||
|
||||
_, statErr := os.Stat(a.args.binPath)
|
||||
if os.IsNotExist(statErr) {
|
||||
err = errors.Join(err, fmt.Errorf("LibreOffice binary path does not exist: %w", statErr))
|
||||
err = errors.Join(err, fmt.Errorf("LibreOffice binary does not exist at %q; check the LIBREOFFICE_BIN_PATH environment variable: %w", a.args.binPath, statErr))
|
||||
}
|
||||
|
||||
_, statErr = os.Stat(a.args.unoBinPath)
|
||||
if os.IsNotExist(statErr) {
|
||||
err = errors.Join(err, fmt.Errorf("unoconverter binary path does not exist: %w", statErr))
|
||||
err = errors.Join(err, fmt.Errorf("unoconverter binary does not exist at %q; check the UNOCONVERTER_BIN_PATH environment variable: %w", a.args.unoBinPath, statErr))
|
||||
}
|
||||
|
||||
return err
|
||||
@@ -520,19 +546,46 @@ func (a *Api) Stop(ctx context.Context) error {
|
||||
|
||||
// Debug returns additional debug data.
|
||||
func (a *Api) Debug() map[string]any {
|
||||
debug := make(map[string]any)
|
||||
return map[string]any{"version": a.detectVersion()}
|
||||
}
|
||||
|
||||
cmd := exec.Command(a.args.binPath, "--version") //nolint:gosec
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
// detectVersion resolves the LibreOffice version once, preferring the value
|
||||
// captured at image build time so it never spawns LibreOffice at runtime. It
|
||||
// falls back to running soffice --version for local or non-Docker builds.
|
||||
func (a *Api) detectVersion() string {
|
||||
a.versionOnce.Do(func() {
|
||||
if v, ok := gotenberg.BuildVersion("libreoffice-api"); ok {
|
||||
a.version = v
|
||||
return
|
||||
}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
debug["version"] = err.Error()
|
||||
return debug
|
||||
cmd := exec.Command(a.args.binPath, "--version") //nolint:gosec
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
a.version = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
a.version = strings.TrimSpace(string(output))
|
||||
})
|
||||
|
||||
return a.version
|
||||
}
|
||||
|
||||
// spanAttrs returns the client-span attributes for a LibreOffice invocation:
|
||||
// the server address and the LibreOffice version, plus any extra attributes.
|
||||
// The version rides on every conversion span so a trace records which
|
||||
// LibreOffice rendered the document.
|
||||
func (a *Api) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue {
|
||||
attrs := make([]attribute.KeyValue, 0, 2+len(extra))
|
||||
attrs = append(attrs, semconv.ServerAddress(a.args.binPath))
|
||||
if v := a.detectVersion(); v != "" {
|
||||
attrs = append(attrs, attribute.String("gotenberg.libreoffice.version", v))
|
||||
}
|
||||
|
||||
debug["version"] = strings.TrimSpace(string(output))
|
||||
return debug
|
||||
return append(attrs, extra...)
|
||||
}
|
||||
|
||||
// Metrics returns the metrics.
|
||||
@@ -608,76 +661,123 @@ func (a *Api) LibreOffice() (Uno, error) {
|
||||
func (a *Api) Pdf(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "libreoffice.Pdf",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(a.args.binPath)),
|
||||
trace.WithAttributes(a.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
start := time.Now()
|
||||
var conversionStart time.Time
|
||||
span.SetAttributes(
|
||||
attribute.Int64("gotenberg.queue.depth_at_arrival", a.supervisor.ReqQueueSize()),
|
||||
attribute.Int64("gotenberg.conversions_since_last_restart", a.supervisor.ConversionsSinceRestart()),
|
||||
)
|
||||
span.SetAttributes(conversionRequestAttributes(inputPath, options)...)
|
||||
|
||||
err := a.supervisor.Run(ctx, logger, func() error {
|
||||
conversionStart = time.Now()
|
||||
return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options)
|
||||
})
|
||||
// ErrCoreDumped happens randomly (https://github.com/gotenberg/gotenberg/issues/639);
|
||||
// retry the conversion, but cap the retries so a permanently failing
|
||||
// document cannot loop forever. Each attempt records its own metrics.
|
||||
const maxCoreDumpedRetries = 10
|
||||
|
||||
// Determine status and error reason.
|
||||
status := "success"
|
||||
reason := ""
|
||||
var err error
|
||||
var reason string
|
||||
for attempt := 0; ; attempt++ {
|
||||
start := time.Now()
|
||||
var conversionStart time.Time
|
||||
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
status = "timeout"
|
||||
reason = "timeout"
|
||||
case errors.Is(err, context.Canceled):
|
||||
err = a.supervisor.Run(ctx, logger, func() error {
|
||||
conversionStart = time.Now()
|
||||
return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options)
|
||||
})
|
||||
|
||||
// Determine status and error reason.
|
||||
status := "success"
|
||||
reason = ""
|
||||
|
||||
if err != nil {
|
||||
status = "error"
|
||||
reason = "context_cancelled"
|
||||
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) || errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
|
||||
status = "error"
|
||||
reason = "libreoffice_unavailable"
|
||||
default:
|
||||
status = "error"
|
||||
reason = "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Record metrics.
|
||||
attrs := metric.WithAttributes(attribute.String("status", status))
|
||||
a.reqsCounter.Add(ctx, 1, attrs)
|
||||
|
||||
if reason != "" {
|
||||
a.errsCounter.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
|
||||
}
|
||||
|
||||
if !conversionStart.IsZero() {
|
||||
queueWait := conversionStart.Sub(start).Seconds()
|
||||
a.queueWaitDurationCounter.Record(ctx, queueWait, attrs)
|
||||
|
||||
conversionDuration := time.Since(conversionStart).Seconds()
|
||||
a.conversionDurationCounter.Record(ctx, conversionDuration, attrs)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
stat, statErr := os.Stat(outputPath)
|
||||
if statErr == nil {
|
||||
a.pdfOutputSizeCounter.Record(ctx, stat.Size(), attrs)
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
status = "timeout"
|
||||
}
|
||||
reason = libreofficeErrorType(err)
|
||||
}
|
||||
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/639.
|
||||
if errors.Is(err, ErrCoreDumped) {
|
||||
logger.DebugContext(ctx, fmt.Sprintf("got a '%s' error, retry conversion", err))
|
||||
return a.Pdf(ctx, logger, inputPath, outputPath, options)
|
||||
// Record metrics for this attempt.
|
||||
attrs := metric.WithAttributes(attribute.String("status", status))
|
||||
a.reqsCounter.Add(ctx, 1, attrs)
|
||||
|
||||
if reason != "" {
|
||||
a.errsCounter.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
|
||||
}
|
||||
|
||||
if !conversionStart.IsZero() {
|
||||
queueWait := conversionStart.Sub(start).Seconds()
|
||||
a.queueWaitDurationCounter.Record(ctx, queueWait, attrs)
|
||||
|
||||
conversionDuration := time.Since(conversionStart).Seconds()
|
||||
a.conversionDurationCounter.Record(ctx, conversionDuration, attrs)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
stat, statErr := os.Stat(outputPath)
|
||||
if statErr == nil {
|
||||
a.pdfOutputSizeCounter.Record(ctx, stat.Size(), attrs)
|
||||
span.SetAttributes(attribute.Int64("gotenberg.conversion.output.bytes", stat.Size()))
|
||||
}
|
||||
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrCoreDumped) && attempt < maxCoreDumpedRetries {
|
||||
logger.DebugContext(ctx, fmt.Sprintf("got a '%s' error, retry conversion (attempt %d)", err, attempt+1))
|
||||
span.AddEvent("conversion.retry", trace.WithAttributes(
|
||||
attribute.Int("attempt", attempt+1),
|
||||
))
|
||||
a.coreDumpedRetriesCounter.Add(ctx, 1)
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
gotenberg.SpanErrorType(span, reason)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return fmt.Errorf("supervisor run task: %w", err)
|
||||
}
|
||||
|
||||
// conversionRequestAttributes derives low-cardinality attributes describing the
|
||||
// requested conversion: the input document size and the requested PDF format
|
||||
// options.
|
||||
func conversionRequestAttributes(inputPath string, options Options) []attribute.KeyValue {
|
||||
attrs := []attribute.KeyValue{
|
||||
attribute.String("gotenberg.libreoffice.pdf_a", options.PdfFormats.PdfA),
|
||||
attribute.Bool("gotenberg.libreoffice.pdf_ua", options.PdfFormats.PdfUa),
|
||||
attribute.Bool("gotenberg.conversion.landscape", options.Landscape),
|
||||
attribute.Bool("gotenberg.conversion.has_page_ranges", options.PageRanges != ""),
|
||||
}
|
||||
|
||||
if info, err := os.Stat(inputPath); err == nil {
|
||||
attrs = append(attrs, attribute.Int64("gotenberg.conversion.input.bytes", info.Size()))
|
||||
}
|
||||
|
||||
return attrs
|
||||
}
|
||||
|
||||
// libreofficeErrorType maps a conversion error to LibreOffice's bounded reason
|
||||
// value, reused as the span error.type. Generic failures fall back to
|
||||
// [gotenberg.ClassifyError].
|
||||
func libreofficeErrorType(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidPdfFormats):
|
||||
return gotenberg.ErrorTypeInvalidInput
|
||||
case errors.Is(err, ErrUnoException), errors.Is(err, ErrRuntimeException):
|
||||
return "libreoffice_exception"
|
||||
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded), errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
|
||||
return "libreoffice_unavailable"
|
||||
default:
|
||||
return gotenberg.ClassifyError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Extensions returns the file extensions available for conversions.
|
||||
// FIXME: don't care, take all on the route level?
|
||||
func (a *Api) Extensions() []string {
|
||||
|
||||
44
pkg/modules/libreoffice/api/api_request_attrs_test.go
Normal file
44
pkg/modules/libreoffice/api/api_request_attrs_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestConversionRequestAttributes(t *testing.T) {
|
||||
tmp := filepath.Join(t.TempDir(), "in.docx")
|
||||
content := []byte("hello world")
|
||||
if err := os.WriteFile(tmp, content, 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
options := Options{
|
||||
Landscape: true,
|
||||
PageRanges: "1-3",
|
||||
PdfFormats: gotenberg.PdfFormats{PdfA: "PDF/A-2b", PdfUa: true},
|
||||
}
|
||||
|
||||
got := map[string]any{}
|
||||
for _, kv := range conversionRequestAttributes(tmp, options) {
|
||||
got[string(kv.Key)] = kv.Value.AsInterface()
|
||||
}
|
||||
|
||||
if got["gotenberg.libreoffice.pdf_a"] != "PDF/A-2b" {
|
||||
t.Errorf("pdf_a = %v, want PDF/A-2b", got["gotenberg.libreoffice.pdf_a"])
|
||||
}
|
||||
if got["gotenberg.libreoffice.pdf_ua"] != true {
|
||||
t.Errorf("pdf_ua = %v, want true", got["gotenberg.libreoffice.pdf_ua"])
|
||||
}
|
||||
if got["gotenberg.conversion.landscape"] != true {
|
||||
t.Errorf("landscape = %v, want true", got["gotenberg.conversion.landscape"])
|
||||
}
|
||||
if got["gotenberg.conversion.has_page_ranges"] != true {
|
||||
t.Errorf("has_page_ranges = %v, want true", got["gotenberg.conversion.has_page_ranges"])
|
||||
}
|
||||
if got["gotenberg.conversion.input.bytes"] != int64(len(content)) {
|
||||
t.Errorf("input.bytes = %v, want %d", got["gotenberg.conversion.input.bytes"], len(content))
|
||||
}
|
||||
}
|
||||
84
pkg/modules/libreoffice/api/core_dumped_test.go
Normal file
84
pkg/modules/libreoffice/api/core_dumped_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func sumInt64Counter(rm metricdata.ResourceMetrics, name string) int64 {
|
||||
var total int64
|
||||
for _, sm := range rm.ScopeMetrics {
|
||||
for _, m := range sm.Metrics {
|
||||
if m.Name != name {
|
||||
continue
|
||||
}
|
||||
if sum, ok := m.Data.(metricdata.Sum[int64]); ok {
|
||||
for _, dp := range sum.DataPoints {
|
||||
total += dp.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func TestApi_Pdf_CoreDumpedRetryCap(t *testing.T) {
|
||||
reader := sdkmetric.NewManualReader()
|
||||
provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
|
||||
previous := otel.GetMeterProvider()
|
||||
otel.SetMeterProvider(provider)
|
||||
t.Cleanup(func() { otel.SetMeterProvider(previous) })
|
||||
|
||||
var runCalls int
|
||||
supervisor := &gotenberg.ProcessSupervisorMock{
|
||||
RunMock: func(_ context.Context, _ *slog.Logger, _ func() error) error {
|
||||
runCalls++
|
||||
return ErrCoreDumped
|
||||
},
|
||||
ReqQueueSizeMock: func() int64 { return 0 },
|
||||
ConversionsSinceRestartMock: func() int64 { return 0 },
|
||||
}
|
||||
|
||||
a := &Api{supervisor: supervisor}
|
||||
meter := gotenberg.Meter()
|
||||
a.reqsCounter, _ = meter.Int64Counter("libreoffice.requests.total")
|
||||
a.errsCounter, _ = meter.Int64Counter("libreoffice.errors.total")
|
||||
a.conversionDurationCounter, _ = meter.Float64Histogram("libreoffice.conversion.duration")
|
||||
a.queueWaitDurationCounter, _ = meter.Float64Histogram("libreoffice.queue.wait.duration")
|
||||
a.pdfOutputSizeCounter, _ = meter.Int64Histogram("libreoffice.pdf.output.size")
|
||||
a.coreDumpedRetriesCounter, _ = meter.Int64Counter("libreoffice.conversion.retries.total")
|
||||
|
||||
err := a.Pdf(context.Background(), slog.New(slog.DiscardHandler), "/nonexistent/in.docx", "/tmp/out.pdf", Options{})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error after exhausting the retries")
|
||||
}
|
||||
if !errors.Is(err, ErrCoreDumped) {
|
||||
t.Errorf("expected ErrCoreDumped, got %v", err)
|
||||
}
|
||||
|
||||
// 1 initial attempt + 10 retries.
|
||||
if runCalls != 11 {
|
||||
t.Errorf("supervisor.Run called %d times, want 11", runCalls)
|
||||
}
|
||||
|
||||
var rm metricdata.ResourceMetrics
|
||||
if err := reader.Collect(context.Background(), &rm); err != nil {
|
||||
t.Fatalf("collect: %v", err)
|
||||
}
|
||||
|
||||
if retries := sumInt64Counter(rm, "libreoffice.conversion.retries.total"); retries != 10 {
|
||||
t.Errorf("retries counter = %d, want 10", retries)
|
||||
}
|
||||
// Per-attempt request metric must be preserved: one per attempt.
|
||||
if reqs := sumInt64Counter(rm, "libreoffice.requests.total"); reqs != 11 {
|
||||
t.Errorf("requests counter = %d, want 11", reqs)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user