mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-15 20:02:15 +01:00
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0f487e333 | ||
|
|
3de6932279 | ||
|
|
e9a67132ec | ||
|
|
41b33fd6ad | ||
|
|
2521485bf8 | ||
|
|
65e5699b71 | ||
|
|
b3c06fb8ae | ||
|
|
f90684e165 | ||
|
|
90e614afa4 | ||
|
|
6a087cb1f7 | ||
|
|
38db892552 | ||
|
|
ca9603bdf4 | ||
|
|
0c1e82c885 | ||
|
|
1303e0ebc9 | ||
|
|
cc1341c3cf | ||
|
|
1ac1d9887e | ||
|
|
7a730cfdc2 | ||
|
|
b213f2ffed | ||
|
|
8d1eeaa73a | ||
|
|
2f6818d3df | ||
|
|
38f6e466d4 | ||
|
|
e39840d5cf | ||
|
|
9b48d3f84e | ||
|
|
05bde96334 | ||
|
|
8b2c15d5de | ||
|
|
8d327a5196 | ||
|
|
dc61c3631e | ||
|
|
dc7c68152c | ||
|
|
357c3b4a59 | ||
|
|
91f2587fef | ||
|
|
9ec7be4687 | ||
|
|
de7f335791 | ||
|
|
815f586315 | ||
|
|
b71df026f6 | ||
|
|
8d29638b74 | ||
|
|
63c9a36599 | ||
|
|
31fa392db2 | ||
|
|
bb0b874d16 | ||
|
|
60f5a7b996 | ||
|
|
8b559eb699 | ||
|
|
50e8e44bc1 | ||
|
|
b243283c9b | ||
|
|
a0d5e93ced | ||
|
|
7568fc379b | ||
|
|
5b21dbf1a9 | ||
|
|
0a62359691 | ||
|
|
7418b5cab3 | ||
|
|
337a5cca64 | ||
|
|
387fce2cf6 | ||
|
|
c89991cc0d | ||
|
|
0c8d681c35 | ||
|
|
3d300c2a09 | ||
|
|
8aa5a4f0e7 | ||
|
|
a92a7fedba | ||
|
|
d0e3991d16 | ||
|
|
d67ef724f9 | ||
|
|
68a0a9e5c1 | ||
|
|
b5f77c3b73 | ||
|
|
be5dcf943d | ||
|
|
9ca1e302bd | ||
|
|
3617bc6587 | ||
|
|
874adf076c | ||
|
|
9d83255ecd | ||
|
|
3b43bfbf33 | ||
|
|
7614964109 | ||
|
|
7b054da4e7 |
@@ -47,6 +47,8 @@ body:multipart-form {
|
|||||||
~splitUnify: false
|
~splitUnify: false
|
||||||
~pdfa: PDF/A-1b
|
~pdfa: PDF/A-1b
|
||||||
~pdfua: true
|
~pdfua: true
|
||||||
|
~optimizeImages: false
|
||||||
|
~imageQuality: 80
|
||||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||||
~userPassword:
|
~userPassword:
|
||||||
~ownerPassword:
|
~ownerPassword:
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ body:multipart-form {
|
|||||||
~splitUnify: false
|
~splitUnify: false
|
||||||
~pdfa: PDF/A-1b
|
~pdfa: PDF/A-1b
|
||||||
~pdfua: true
|
~pdfua: true
|
||||||
|
~optimizeImages: false
|
||||||
|
~imageQuality: 80
|
||||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||||
~userPassword:
|
~userPassword:
|
||||||
~ownerPassword:
|
~ownerPassword:
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ body:multipart-form {
|
|||||||
~splitUnify: false
|
~splitUnify: false
|
||||||
~pdfa: PDF/A-1b
|
~pdfa: PDF/A-1b
|
||||||
~pdfua: true
|
~pdfua: true
|
||||||
|
~optimizeImages: false
|
||||||
|
~imageQuality: 80
|
||||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||||
~userPassword:
|
~userPassword:
|
||||||
~ownerPassword:
|
~ownerPassword:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ body:multipart-form {
|
|||||||
~width: 800
|
~width: 800
|
||||||
~height: 600
|
~height: 600
|
||||||
~clip: false
|
~clip: false
|
||||||
|
~selector:
|
||||||
~format: png
|
~format: png
|
||||||
~quality: 100
|
~quality: 100
|
||||||
~optimizeForSpeed: false
|
~optimizeForSpeed: false
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ body:multipart-form {
|
|||||||
~width: 800
|
~width: 800
|
||||||
~height: 600
|
~height: 600
|
||||||
~clip: false
|
~clip: false
|
||||||
|
~selector:
|
||||||
~format: png
|
~format: png
|
||||||
~quality: 100
|
~quality: 100
|
||||||
~optimizeForSpeed: false
|
~optimizeForSpeed: false
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ body:multipart-form {
|
|||||||
~width: 800
|
~width: 800
|
||||||
~height: 600
|
~height: 600
|
||||||
~clip: false
|
~clip: false
|
||||||
|
~selector:
|
||||||
~format: png
|
~format: png
|
||||||
~quality: 100
|
~quality: 100
|
||||||
~optimizeForSpeed: false
|
~optimizeForSpeed: false
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ body:multipart-form {
|
|||||||
~splitUnify: false
|
~splitUnify: false
|
||||||
~pdfa: PDF/A-1b
|
~pdfa: PDF/A-1b
|
||||||
~pdfua: true
|
~pdfua: true
|
||||||
|
~optimizeImages: false
|
||||||
|
~imageQuality: 80
|
||||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||||
~userPassword:
|
~userPassword:
|
||||||
~ownerPassword:
|
~ownerPassword:
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ body:multipart-form {
|
|||||||
files: @file(../../test/integration/testdata/page_2.pdf)
|
files: @file(../../test/integration/testdata/page_2.pdf)
|
||||||
~flatten: false
|
~flatten: false
|
||||||
~autoIndexBookmarks: false
|
~autoIndexBookmarks: false
|
||||||
|
~titleBookmarks: false
|
||||||
~pdfa: PDF/A-1b
|
~pdfa: PDF/A-1b
|
||||||
~pdfua: true
|
~pdfua: true
|
||||||
|
~optimizeImages: false
|
||||||
|
~imageQuality: 80
|
||||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||||
~bookmarks: [{"title":"Page 1","page":1},{"title":"Page 2","page":2}]
|
~bookmarks: [{"title":"Page 1","page":1},{"title":"Page 2","page":2}]
|
||||||
~userPassword:
|
~userPassword:
|
||||||
|
|||||||
26
.bruno/PDF Engines/Optimize/Optimize PDF.bru
Normal file
26
.bruno/PDF Engines/Optimize/Optimize PDF.bru
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
meta {
|
||||||
|
name: Optimize PDF
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{baseUrl}}/forms/pdfengines/optimize
|
||||||
|
body: multipartForm
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
body:multipart-form {
|
||||||
|
files: @file(../../test/integration/testdata/page_1.pdf)
|
||||||
|
~imageQuality: 80
|
||||||
|
}
|
||||||
|
|
||||||
|
headers {
|
||||||
|
~Gotenberg-Output-Filename: optimized
|
||||||
|
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
|
||||||
|
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
|
||||||
|
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
|
||||||
|
~Gotenberg-Webhook-Method: POST
|
||||||
|
~Gotenberg-Webhook-Error-Method: POST
|
||||||
|
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ body:multipart-form {
|
|||||||
~flatten: false
|
~flatten: false
|
||||||
~pdfa: PDF/A-1b
|
~pdfa: PDF/A-1b
|
||||||
~pdfua: true
|
~pdfua: true
|
||||||
|
~optimizeImages: false
|
||||||
|
~imageQuality: 80
|
||||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||||
~userPassword:
|
~userPassword:
|
||||||
~ownerPassword:
|
~ownerPassword:
|
||||||
|
|||||||
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
@@ -12,6 +12,12 @@ updates:
|
|||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
ignore:
|
||||||
|
# Held at v0.14.2: v0.15.x breaks the headless print-mode paint pipeline
|
||||||
|
# (rAF / ResizeObserver / IntersectionObserver stop firing, blank charts).
|
||||||
|
# See https://github.com/gotenberg/gotenberg/issues/1535.
|
||||||
|
- dependency-name: "github.com/chromedp/chromedp"
|
||||||
|
- dependency-name: "github.com/chromedp/cdproto"
|
||||||
- package-ecosystem: "npm"
|
- package-ecosystem: "npm"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
|
|||||||
12
.github/workflows/continuous-delivery.yml
vendored
12
.github/workflows/continuous-delivery.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: build_push
|
id: build_push
|
||||||
@@ -50,7 +50,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: build_push
|
id: build_push
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: build_push
|
id: build_push
|
||||||
@@ -104,7 +104,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: build_push
|
id: build_push
|
||||||
@@ -131,7 +131,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: build_push
|
id: build_push
|
||||||
@@ -154,7 +154,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Merge
|
- name: Merge
|
||||||
uses: ./.github/actions/merge
|
uses: ./.github/actions/merge
|
||||||
|
|||||||
38
.github/workflows/continuous-integration.yml
vendored
38
.github/workflows/continuous-integration.yml
vendored
@@ -21,27 +21,27 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: Run linters
|
- name: Run linters
|
||||||
uses: golangci/golangci-lint-action@v9
|
uses: golangci/golangci-lint-action@v9
|
||||||
with:
|
with:
|
||||||
version: v2.10.1
|
version: v2.12.2
|
||||||
|
|
||||||
lint-prettier:
|
lint-prettier:
|
||||||
name: Lint non-Golang codebase
|
name: Lint non-Golang codebase
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version-file: .node-version
|
node-version-file: .node-version
|
||||||
|
|
||||||
@@ -59,10 +59,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build, test and push
|
- name: Build, test and push
|
||||||
id: build_test_push
|
id: build_test_push
|
||||||
@@ -117,7 +117,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build, test and push
|
- name: Build, test and push
|
||||||
id: build_test_push
|
id: build_test_push
|
||||||
@@ -147,7 +147,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build, test and push
|
- name: Build, test and push
|
||||||
id: build_test_push
|
id: build_test_push
|
||||||
@@ -177,7 +177,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build, test and push
|
- name: Build, test and push
|
||||||
id: build_test_push
|
id: build_test_push
|
||||||
@@ -207,7 +207,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build, test and push
|
- name: Build, test and push
|
||||||
id: build_test_push
|
id: build_test_push
|
||||||
@@ -253,7 +253,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Merge
|
- name: Merge
|
||||||
uses: ./.github/actions/merge
|
uses: ./.github/actions/merge
|
||||||
@@ -322,7 +322,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build, test and push
|
- name: Build, test and push
|
||||||
id: build_test_push
|
id: build_test_push
|
||||||
@@ -351,7 +351,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build, test and push
|
- name: Build, test and push
|
||||||
id: build_test_push
|
id: build_test_push
|
||||||
@@ -380,7 +380,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build, test and push
|
- name: Build, test and push
|
||||||
id: build_test_push
|
id: build_test_push
|
||||||
@@ -409,7 +409,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build, test and push
|
- name: Build, test and push
|
||||||
id: build_test_push
|
id: build_test_push
|
||||||
@@ -438,7 +438,7 @@ jobs:
|
|||||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build, test and push
|
- name: Build, test and push
|
||||||
id: build_test_push
|
id: build_test_push
|
||||||
@@ -460,7 +460,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Merge
|
- name: Merge
|
||||||
uses: ./.github/actions/merge
|
uses: ./.github/actions/merge
|
||||||
|
|||||||
2
.github/workflows/pull-request-cleanup.yml
vendored
2
.github/workflows/pull-request-cleanup.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
|||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Cleanup
|
- name: Cleanup
|
||||||
uses: ./.github/actions/clean
|
uses: ./.github/actions/clean
|
||||||
|
|||||||
@@ -121,20 +121,28 @@ Enforced by `gci`: standard library, then third-party, then `github.com/gotenber
|
|||||||
Every exported type and function has a Godoc comment starting with its identifier name:
|
Every exported type and function has a Godoc comment starting with its identifier name:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// Violation records a single rule violation with context.
|
// OutboundDecision is the result of validating an outbound URL via
|
||||||
type Violation struct { ... }
|
// [DecideOutbound]. ...
|
||||||
|
type OutboundDecision struct { ... }
|
||||||
|
|
||||||
// ValidatePDFA audits the document against a PDF/A profile.
|
// DialPinned dials each addr in turn until one connects, returning the
|
||||||
func ValidatePDFA(ctx context.Context, ...) ([]error, error)
|
// first successful connection or the last error. ...
|
||||||
|
func DialPinned(ctx context.Context, network string, addrs []netip.Addr, port string) (net.Conn, error)
|
||||||
```
|
```
|
||||||
|
|
||||||
Each package should have a `doc.go` with a `// Package foo ...` comment.
|
Each package should have a `doc.go` with a `// Package foo ...` comment:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Package api manages a LibreOffice instance via the UNO API.
|
||||||
|
package api
|
||||||
|
```
|
||||||
|
|
||||||
Reference identifiers with `[Name]` brackets for pkg.go.dev linking:
|
Reference identifiers with `[Name]` brackets for pkg.go.dev linking:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// ValidatePDFA returns violations as []error where each element
|
// Callers pass the Pinned slice from [OutboundDecision] so that the dial
|
||||||
// is a [Violation] value. See [Rule] for the structured fields.
|
// targets exactly the IPs that [DecideOutbound] resolved, preventing DNS
|
||||||
|
// rebinding between validation and connect.
|
||||||
```
|
```
|
||||||
|
|
||||||
### Code comments
|
### Code comments
|
||||||
|
|||||||
25
Makefile
25
Makefile
@@ -18,6 +18,8 @@ GOTENBERG_BUILD_DEBUG_DATA=true
|
|||||||
API_PORT=3000
|
API_PORT=3000
|
||||||
API_PORT_FROM_ENV=
|
API_PORT_FROM_ENV=
|
||||||
API_BIND_IP=
|
API_BIND_IP=
|
||||||
|
API_TLS_CERT_FILE=
|
||||||
|
API_TLS_KEY_FILE=
|
||||||
API_START_TIMEOUT=30s
|
API_START_TIMEOUT=30s
|
||||||
API_TIMEOUT=30s
|
API_TIMEOUT=30s
|
||||||
API_BODY_LIMIT=
|
API_BODY_LIMIT=
|
||||||
@@ -26,8 +28,15 @@ API_CORRELATION_ID_HEADER=Gotenberg-Trace
|
|||||||
API_ENABLE_BASIC_AUTH=false
|
API_ENABLE_BASIC_AUTH=false
|
||||||
GOTENBERG_API_BASIC_AUTH_USERNAME=
|
GOTENBERG_API_BASIC_AUTH_USERNAME=
|
||||||
GOTENBERG_API_BASIC_AUTH_PASSWORD=
|
GOTENBERG_API_BASIC_AUTH_PASSWORD=
|
||||||
|
API_ENABLE_OIDC_AUTH=false
|
||||||
|
API_OIDC_ISSUER=
|
||||||
|
API_OIDC_AUDIENCE=
|
||||||
|
API_OIDC_JWKS_URL=
|
||||||
API_DOWNLOAD_FROM_ALLOW_LIST=
|
API_DOWNLOAD_FROM_ALLOW_LIST=
|
||||||
API_DOWNLOAD_FROM_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)
|
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_DOWNLOAD_FROM_MAX_RETRY=4
|
||||||
API_DISABLE_DOWNLOAD_FROM=false
|
API_DISABLE_DOWNLOAD_FROM=false
|
||||||
API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=true
|
API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=true
|
||||||
@@ -47,8 +56,11 @@ CHROMIUM_DISABLE_WEB_SECURITY=false
|
|||||||
CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false
|
CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false
|
||||||
CHROMIUM_HOST_RESOLVER_RULES=
|
CHROMIUM_HOST_RESOLVER_RULES=
|
||||||
CHROMIUM_PROXY_SERVER=
|
CHROMIUM_PROXY_SERVER=
|
||||||
|
CHROMIUM_ENABLE_ENVIRONMENT_PROXY=false
|
||||||
CHROMIUM_ALLOW_LIST=
|
CHROMIUM_ALLOW_LIST=
|
||||||
CHROMIUM_DENY_LIST=^file:(?!//\/tmp/).*
|
CHROMIUM_DENY_LIST=^file:(?!//\/tmp/).*
|
||||||
|
CHROMIUM_DENY_PRIVATE_IPS=false
|
||||||
|
CHROMIUM_DENY_PUBLIC_IPS=false
|
||||||
CHROMIUM_CLEAR_CACHE=false
|
CHROMIUM_CLEAR_CACHE=false
|
||||||
CHROMIUM_CLEAR_COOKIES=false
|
CHROMIUM_CLEAR_COOKIES=false
|
||||||
CHROMIUM_DISABLE_JAVASCRIPT=false
|
CHROMIUM_DISABLE_JAVASCRIPT=false
|
||||||
@@ -60,6 +72,9 @@ LIBREOFFICE_AUTO_START=false
|
|||||||
LIBREOFFICE_START_TIMEOUT=20s
|
LIBREOFFICE_START_TIMEOUT=20s
|
||||||
LIBREOFFICE_ALLOW_LIST=
|
LIBREOFFICE_ALLOW_LIST=
|
||||||
LIBREOFFICE_DENY_LIST=
|
LIBREOFFICE_DENY_LIST=
|
||||||
|
LIBREOFFICE_DENY_PRIVATE_IPS=false
|
||||||
|
LIBREOFFICE_DENY_PUBLIC_IPS=false
|
||||||
|
LIBREOFFICE_ENABLE_ENVIRONMENT_PROXY=false
|
||||||
LIBREOFFICE_DISABLE_ROUTES=false
|
LIBREOFFICE_DISABLE_ROUTES=false
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
LOG_FIELDS_PREFIX=
|
LOG_FIELDS_PREFIX=
|
||||||
@@ -71,6 +86,7 @@ PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
|
|||||||
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
|
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
|
||||||
PDFENGINES_FLATTEN_ENGINES=qpdf
|
PDFENGINES_FLATTEN_ENGINES=qpdf
|
||||||
PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
|
PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
|
||||||
|
PDFENGINES_OPTIMIZE_IMAGES_ENGINES=pdfcpu
|
||||||
PDFENGINES_READ_METADATA_ENGINES=exiftool
|
PDFENGINES_READ_METADATA_ENGINES=exiftool
|
||||||
PDFENGINES_WRITE_METADATA_ENGINES=exiftool
|
PDFENGINES_WRITE_METADATA_ENGINES=exiftool
|
||||||
PDFENGINES_READ_BOOKMARKS_ENGINES=pdfcpu
|
PDFENGINES_READ_BOOKMARKS_ENGINES=pdfcpu
|
||||||
@@ -79,7 +95,7 @@ PDFENGINES_WATERMARK_ENGINES=pdfcpu,pdftk
|
|||||||
PDFENGINES_STAMP_ENGINES=pdfcpu,pdftk
|
PDFENGINES_STAMP_ENGINES=pdfcpu,pdftk
|
||||||
PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
|
PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
|
||||||
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
|
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
|
||||||
PDFENGINES_EMBED_ENGINES=qpdf,pdfcpu
|
PDFENGINES_EMBED_ENGINES=pdfcpu
|
||||||
PDFENGINES_EMBED_METADATA_ENGINES=qpdf
|
PDFENGINES_EMBED_METADATA_ENGINES=qpdf
|
||||||
PDFENGINES_FACTUR_X_ENGINES=qpdf
|
PDFENGINES_FACTUR_X_ENGINES=qpdf
|
||||||
PROMETHEUS_NAMESPACE=gotenberg
|
PROMETHEUS_NAMESPACE=gotenberg
|
||||||
@@ -97,6 +113,9 @@ OTEL_EXPORTER_OTLP_INSECURE=true
|
|||||||
WEBHOOK_ENABLE_SYNC_MODE=false
|
WEBHOOK_ENABLE_SYNC_MODE=false
|
||||||
WEBHOOK_ALLOW_LIST=
|
WEBHOOK_ALLOW_LIST=
|
||||||
WEBHOOK_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)
|
WEBHOOK_DENY_LIST=^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_MAX_RETRY=4
|
||||||
WEBHOOK_RETRY_MIN_WAIT=1s
|
WEBHOOK_RETRY_MIN_WAIT=1s
|
||||||
WEBHOOK_RETRY_MAX_WAIT=30s
|
WEBHOOK_RETRY_MAX_WAIT=30s
|
||||||
@@ -133,10 +152,12 @@ NO_CONCURRENCY=false
|
|||||||
# chromium-screenshot-html
|
# chromium-screenshot-html
|
||||||
# chromium-screenshot-markdown
|
# chromium-screenshot-markdown
|
||||||
# chromium-screenshot-url
|
# chromium-screenshot-url
|
||||||
|
# chromium-ssrf
|
||||||
# debug
|
# debug
|
||||||
# health
|
# health
|
||||||
# libreoffice
|
# libreoffice
|
||||||
# libreoffice-convert
|
# libreoffice-convert
|
||||||
|
# libreoffice-ssrf
|
||||||
# output-filename
|
# output-filename
|
||||||
# pdfengines
|
# pdfengines
|
||||||
# pdfengines-convert
|
# pdfengines-convert
|
||||||
@@ -146,6 +167,8 @@ NO_CONCURRENCY=false
|
|||||||
# encrypt
|
# encrypt
|
||||||
# pdfengines-flatten
|
# pdfengines-flatten
|
||||||
# flatten
|
# flatten
|
||||||
|
# pdfengines-optimize
|
||||||
|
# optimize
|
||||||
# pdfengines-merge
|
# pdfengines-merge
|
||||||
# merge
|
# merge
|
||||||
# pdfengines-metadata
|
# pdfengines-metadata
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# ARG instructions do not create additional layers. Instead, next layers will
|
# ARG instructions do not create additional layers. Instead, next layers will
|
||||||
# concatenate them. Also, we have to repeat ARG instructions in each build
|
# concatenate them. Also, we have to repeat ARG instructions in each build
|
||||||
# stage that uses them.
|
# stage that uses them.
|
||||||
ARG GOLANG_VERSION=1.26.2
|
ARG GOLANG_VERSION=1.26.5
|
||||||
|
|
||||||
# ----------------------------------------------
|
# ----------------------------------------------
|
||||||
# pdfcpu binary build stage
|
# pdfcpu binary build stage
|
||||||
@@ -11,7 +11,7 @@ ARG GOLANG_VERSION=1.26.2
|
|||||||
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
|
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
|
||||||
|
|
||||||
# See https://github.com/pdfcpu/pdfcpu/releases.
|
# See https://github.com/pdfcpu/pdfcpu/releases.
|
||||||
ARG PDFCPU_VERSION=v0.12.0
|
ARG PDFCPU_VERSION=v0.15.0
|
||||||
ENV CGO_ENABLED=0
|
ENV CGO_ENABLED=0
|
||||||
|
|
||||||
# Define the working directory outside of $GOPATH (we're using go modules).
|
# Define the working directory outside of $GOPATH (we're using go modules).
|
||||||
@@ -88,7 +88,7 @@ RUN apt-get update -qq \
|
|||||||
|
|
||||||
WORKDIR /downloads
|
WORKDIR /downloads
|
||||||
|
|
||||||
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.3.0/unoconv -o unoconverter \
|
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.4.0/unoconv -o unoconverter \
|
||||||
&& chmod +x unoconverter
|
&& chmod +x unoconverter
|
||||||
|
|
||||||
RUN curl -o pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" \
|
RUN curl -o pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" \
|
||||||
|
|||||||
15
compose.yaml
15
compose.yaml
@@ -21,6 +21,8 @@ services:
|
|||||||
- "--api-port=${API_PORT}"
|
- "--api-port=${API_PORT}"
|
||||||
- "--api-port-from-env=${API_PORT_FROM_ENV}"
|
- "--api-port-from-env=${API_PORT_FROM_ENV}"
|
||||||
- "--api-bind-ip=${API_BIND_IP}"
|
- "--api-bind-ip=${API_BIND_IP}"
|
||||||
|
- "--api-tls-cert-file=${API_TLS_CERT_FILE}"
|
||||||
|
- "--api-tls-key-file=${API_TLS_KEY_FILE}"
|
||||||
- "--api-start-timeout=${API_START_TIMEOUT}"
|
- "--api-start-timeout=${API_START_TIMEOUT}"
|
||||||
- "--api-timeout=${API_TIMEOUT}"
|
- "--api-timeout=${API_TIMEOUT}"
|
||||||
- "--api-body-limit=${API_BODY_LIMIT}"
|
- "--api-body-limit=${API_BODY_LIMIT}"
|
||||||
@@ -29,6 +31,9 @@ services:
|
|||||||
- "--api-enable-basic-auth=${API_ENABLE_BASIC_AUTH}"
|
- "--api-enable-basic-auth=${API_ENABLE_BASIC_AUTH}"
|
||||||
- "--api-download-from-allow-list=${API_DOWNLOAD_FROM_ALLOW_LIST}"
|
- "--api-download-from-allow-list=${API_DOWNLOAD_FROM_ALLOW_LIST}"
|
||||||
- "--api-download-from-deny-list=${API_DOWNLOAD_FROM_DENY_LIST}"
|
- "--api-download-from-deny-list=${API_DOWNLOAD_FROM_DENY_LIST}"
|
||||||
|
- "--api-download-from-deny-private-ips=${API_DOWNLOAD_FROM_DENY_PRIVATE_IPS}"
|
||||||
|
- "--api-download-from-deny-public-ips=${API_DOWNLOAD_FROM_DENY_PUBLIC_IPS}"
|
||||||
|
- "--api-download-from-enable-environment-proxy=${API_DOWNLOAD_FROM_ENABLE_ENVIRONMENT_PROXY}"
|
||||||
- "--api-download-from-max-retry=${API_DOWNLOAD_FROM_MAX_RETRY}"
|
- "--api-download-from-max-retry=${API_DOWNLOAD_FROM_MAX_RETRY}"
|
||||||
- "--api-disable-download-from=${API_DISABLE_DOWNLOAD_FROM}"
|
- "--api-disable-download-from=${API_DISABLE_DOWNLOAD_FROM}"
|
||||||
- "--api-disable-health-check-route-telemetry=${API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY}"
|
- "--api-disable-health-check-route-telemetry=${API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY}"
|
||||||
@@ -48,8 +53,11 @@ services:
|
|||||||
- "--chromium-allow-file-access-from-files=${CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES}"
|
- "--chromium-allow-file-access-from-files=${CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES}"
|
||||||
- "--chromium-host-resolver-rules=${CHROMIUM_HOST_RESOLVER_RULES}"
|
- "--chromium-host-resolver-rules=${CHROMIUM_HOST_RESOLVER_RULES}"
|
||||||
- "--chromium-proxy-server=${CHROMIUM_PROXY_SERVER}"
|
- "--chromium-proxy-server=${CHROMIUM_PROXY_SERVER}"
|
||||||
|
- "--chromium-enable-environment-proxy=${CHROMIUM_ENABLE_ENVIRONMENT_PROXY}"
|
||||||
- "--chromium-allow-list=${CHROMIUM_ALLOW_LIST}"
|
- "--chromium-allow-list=${CHROMIUM_ALLOW_LIST}"
|
||||||
- "--chromium-deny-list=${CHROMIUM_DENY_LIST}"
|
- "--chromium-deny-list=${CHROMIUM_DENY_LIST}"
|
||||||
|
- "--chromium-deny-private-ips=${CHROMIUM_DENY_PRIVATE_IPS}"
|
||||||
|
- "--chromium-deny-public-ips=${CHROMIUM_DENY_PUBLIC_IPS}"
|
||||||
- "--chromium-clear-cache=${CHROMIUM_CLEAR_CACHE}"
|
- "--chromium-clear-cache=${CHROMIUM_CLEAR_CACHE}"
|
||||||
- "--chromium-clear-cookies=${CHROMIUM_CLEAR_COOKIES}"
|
- "--chromium-clear-cookies=${CHROMIUM_CLEAR_COOKIES}"
|
||||||
- "--chromium-disable-javascript=${CHROMIUM_DISABLE_JAVASCRIPT}"
|
- "--chromium-disable-javascript=${CHROMIUM_DISABLE_JAVASCRIPT}"
|
||||||
@@ -61,6 +69,9 @@ services:
|
|||||||
- "--libreoffice-start-timeout=${LIBREOFFICE_START_TIMEOUT}"
|
- "--libreoffice-start-timeout=${LIBREOFFICE_START_TIMEOUT}"
|
||||||
- "--libreoffice-allow-list=${LIBREOFFICE_ALLOW_LIST}"
|
- "--libreoffice-allow-list=${LIBREOFFICE_ALLOW_LIST}"
|
||||||
- "--libreoffice-deny-list=${LIBREOFFICE_DENY_LIST}"
|
- "--libreoffice-deny-list=${LIBREOFFICE_DENY_LIST}"
|
||||||
|
- "--libreoffice-deny-private-ips=${LIBREOFFICE_DENY_PRIVATE_IPS}"
|
||||||
|
- "--libreoffice-deny-public-ips=${LIBREOFFICE_DENY_PUBLIC_IPS}"
|
||||||
|
- "--libreoffice-enable-environment-proxy=${LIBREOFFICE_ENABLE_ENVIRONMENT_PROXY}"
|
||||||
- "--libreoffice-disable-routes=${LIBREOFFICE_DISABLE_ROUTES}"
|
- "--libreoffice-disable-routes=${LIBREOFFICE_DISABLE_ROUTES}"
|
||||||
- "--log-level=${LOG_LEVEL}"
|
- "--log-level=${LOG_LEVEL}"
|
||||||
- "--log-fields-prefix=${LOG_FIELDS_PREFIX}"
|
- "--log-fields-prefix=${LOG_FIELDS_PREFIX}"
|
||||||
@@ -71,6 +82,7 @@ services:
|
|||||||
- "--pdfengines-split-engines=${PDFENGINES_SPLIT_ENGINES}"
|
- "--pdfengines-split-engines=${PDFENGINES_SPLIT_ENGINES}"
|
||||||
- "--pdfengines-flatten-engines=${PDFENGINES_FLATTEN_ENGINES}"
|
- "--pdfengines-flatten-engines=${PDFENGINES_FLATTEN_ENGINES}"
|
||||||
- "--pdfengines-convert-engines=${PDFENGINES_CONVERT_ENGINES}"
|
- "--pdfengines-convert-engines=${PDFENGINES_CONVERT_ENGINES}"
|
||||||
|
- "--pdfengines-optimize-images-engines=${PDFENGINES_OPTIMIZE_IMAGES_ENGINES}"
|
||||||
- "--pdfengines-read-metadata-engines=${PDFENGINES_READ_METADATA_ENGINES}"
|
- "--pdfengines-read-metadata-engines=${PDFENGINES_READ_METADATA_ENGINES}"
|
||||||
- "--pdfengines-write-metadata-engines=${PDFENGINES_WRITE_METADATA_ENGINES}"
|
- "--pdfengines-write-metadata-engines=${PDFENGINES_WRITE_METADATA_ENGINES}"
|
||||||
- "--pdfengines-read-bookmarks-engines=${PDFENGINES_READ_BOOKMARKS_ENGINES}"
|
- "--pdfengines-read-bookmarks-engines=${PDFENGINES_READ_BOOKMARKS_ENGINES}"
|
||||||
@@ -91,6 +103,9 @@ services:
|
|||||||
- "--webhook-enable-sync-mode=${WEBHOOK_ENABLE_SYNC_MODE}"
|
- "--webhook-enable-sync-mode=${WEBHOOK_ENABLE_SYNC_MODE}"
|
||||||
- "--webhook-allow-list=${WEBHOOK_ALLOW_LIST}"
|
- "--webhook-allow-list=${WEBHOOK_ALLOW_LIST}"
|
||||||
- "--webhook-deny-list=${WEBHOOK_DENY_LIST}"
|
- "--webhook-deny-list=${WEBHOOK_DENY_LIST}"
|
||||||
|
- "--webhook-deny-private-ips=${WEBHOOK_DENY_PRIVATE_IPS}"
|
||||||
|
- "--webhook-deny-public-ips=${WEBHOOK_DENY_PUBLIC_IPS}"
|
||||||
|
- "--webhook-enable-environment-proxy=${WEBHOOK_ENABLE_ENVIRONMENT_PROXY}"
|
||||||
- "--webhook-max-retry=${WEBHOOK_MAX_RETRY}"
|
- "--webhook-max-retry=${WEBHOOK_MAX_RETRY}"
|
||||||
- "--webhook-retry-min-wait=${WEBHOOK_RETRY_MIN_WAIT}"
|
- "--webhook-retry-min-wait=${WEBHOOK_RETRY_MIN_WAIT}"
|
||||||
- "--webhook-retry-max-wait=${WEBHOOK_RETRY_MAX_WAIT}"
|
- "--webhook-retry-max-wait=${WEBHOOK_RETRY_MAX_WAIT}"
|
||||||
|
|||||||
150
go.mod
150
go.mod
@@ -1,40 +1,45 @@
|
|||||||
module github.com/gotenberg/gotenberg/v8
|
module github.com/gotenberg/gotenberg/v8
|
||||||
|
|
||||||
go 1.26.2
|
go 1.26.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alexliesenfeld/health v0.8.1
|
github.com/alexliesenfeld/health v0.8.1
|
||||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
|
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d // pinned with chromedp v0.14.2, see below
|
||||||
github.com/chromedp/chromedp v0.14.2
|
github.com/chromedp/chromedp v0.14.2 // pinned: v0.15.x breaks the headless print-mode paint pipeline (rAF / ResizeObserver / IntersectionObserver stop firing, blank charts). See https://github.com/gotenberg/gotenberg/issues/1535.
|
||||||
github.com/cucumber/godog v0.15.1
|
github.com/cucumber/godog v0.16.0
|
||||||
github.com/dlclark/regexp2 v1.12.0
|
github.com/dlclark/regexp2 v1.12.0
|
||||||
github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df
|
github.com/gomarkdown/markdown v0.0.0-20260725000948-8435af3f5984
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/hashicorp/go-retryablehttp v0.7.8
|
github.com/hashicorp/go-retryablehttp v0.7.8
|
||||||
github.com/labstack/echo/v4 v4.15.2
|
github.com/labstack/echo/v4 v4.15.4
|
||||||
github.com/labstack/gommon v0.5.0
|
github.com/labstack/gommon v0.5.0
|
||||||
github.com/mholt/archives v0.1.5
|
github.com/mholt/archives v0.1.5
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27
|
github.com/microcosm-cc/bluemonday v1.0.27
|
||||||
github.com/moby/moby/api v1.54.2
|
github.com/moby/moby/api v1.55.0
|
||||||
github.com/moby/moby/client v0.4.1
|
github.com/moby/moby/client v0.5.1
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.24.1
|
||||||
github.com/shirou/gopsutil/v4 v4.26.5
|
github.com/shirou/gopsutil/v4 v4.26.7
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/testcontainers/testcontainers-go v0.42.0
|
github.com/testcontainers/testcontainers-go v0.44.0
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0
|
go.opentelemetry.io/contrib/bridges/otelslog v0.20.0
|
||||||
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0
|
go.opentelemetry.io/contrib/exporters/autoexport v0.70.0
|
||||||
go.opentelemetry.io/otel v1.44.0
|
go.opentelemetry.io/otel v1.45.0
|
||||||
go.opentelemetry.io/otel/log v0.20.0
|
go.opentelemetry.io/otel/log v0.21.0
|
||||||
go.opentelemetry.io/otel/metric v1.44.0
|
go.opentelemetry.io/otel/metric v1.45.0
|
||||||
go.opentelemetry.io/otel/sdk v1.44.0
|
go.opentelemetry.io/otel/sdk v1.45.0
|
||||||
go.opentelemetry.io/otel/sdk/log v0.20.0
|
go.opentelemetry.io/otel/sdk/log v0.21.0
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.44.0
|
go.opentelemetry.io/otel/sdk/metric v1.45.0
|
||||||
go.opentelemetry.io/otel/trace v1.44.0
|
go.opentelemetry.io/otel/trace v1.45.0
|
||||||
golang.org/x/net v0.55.0
|
golang.org/x/net v0.58.0
|
||||||
golang.org/x/sync v0.20.0
|
golang.org/x/sync v0.22.0
|
||||||
golang.org/x/term v0.43.0
|
golang.org/x/term v0.45.0
|
||||||
golang.org/x/text v0.37.0
|
golang.org/x/text v0.41.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/coreos/go-oidc/v3 v3.20.0
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -42,11 +47,11 @@ require (
|
|||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/STARRY-S/zip v0.2.3 // indirect
|
github.com/STARRY-S/zip v0.2.3 // indirect
|
||||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
github.com/andybalholm/brotli v1.2.2 // indirect
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bodgit/plumbing v1.3.0 // indirect
|
github.com/bodgit/plumbing v1.3.0 // indirect
|
||||||
github.com/bodgit/sevenzip v1.6.4 // indirect
|
github.com/bodgit/sevenzip v1.6.5 // indirect
|
||||||
github.com/bodgit/windows v1.0.1 // indirect
|
github.com/bodgit/windows v1.0.1 // indirect
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
@@ -57,89 +62,88 @@ require (
|
|||||||
github.com/containerd/log v0.1.0 // indirect
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
github.com/containerd/platforms v0.2.1 // indirect
|
github.com/containerd/platforms v0.2.1 // indirect
|
||||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||||
github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect
|
github.com/cucumber/gherkin/go/v42 v42.0.1 // indirect
|
||||||
github.com/cucumber/messages/go/v21 v21.0.1 // indirect
|
github.com/cucumber/messages/go/v34 v34.2.1 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/distribution/reference v0.6.0 // indirect
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
github.com/docker/go-connections v0.7.0 // indirect
|
github.com/docker/go-connections v0.8.1 // indirect
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
||||||
github.com/ebitengine/purego v0.10.1 // indirect
|
github.com/ebitengine/purego v0.10.2 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||||
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.4 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/gobwas/httphead v0.1.0 // indirect
|
github.com/gobwas/httphead v0.1.0 // indirect
|
||||||
github.com/gobwas/pool v0.2.1 // indirect
|
github.com/gobwas/pool v0.2.1 // indirect
|
||||||
github.com/gobwas/ws v1.4.0 // indirect
|
github.com/gobwas/ws v1.4.0 // indirect
|
||||||
github.com/gofrs/uuid v4.4.0+incompatible // indirect
|
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect
|
||||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||||
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
||||||
github.com/hashicorp/go-memdb v1.3.5 // indirect
|
github.com/hashicorp/go-memdb v1.3.5 // indirect
|
||||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
github.com/klauspost/compress v1.18.6 // indirect
|
github.com/klauspost/compress v1.19.2 // indirect
|
||||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
|
||||||
github.com/magiconair/properties v1.8.10 // indirect
|
github.com/magiconair/properties v1.18.11 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
||||||
github.com/minio/minlz v1.1.1 // indirect
|
github.com/minio/minlz v1.2.0 // indirect
|
||||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
github.com/moby/go-archive v0.2.0 // indirect
|
github.com/moby/go-archive v0.3.3 // indirect
|
||||||
github.com/moby/patternmatcher v0.6.1 // indirect
|
github.com/moby/patternmatcher v0.6.1 // indirect
|
||||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
github.com/moby/sys/sequential v0.7.0 // indirect
|
||||||
github.com/moby/sys/user v0.4.0 // indirect
|
github.com/moby/sys/user v0.4.1 // indirect
|
||||||
github.com/moby/sys/userns v0.1.0 // indirect
|
github.com/moby/sys/userns v0.2.0 // indirect
|
||||||
github.com/moby/term v0.5.2 // indirect
|
github.com/moby/term v0.5.2 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nwaples/rardecode/v2 v2.2.3 // indirect
|
github.com/nwaples/rardecode/v2 v2.3.0 // indirect
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
github.com/pierrec/lz4/v4 v4.1.26 // indirect
|
github.com/pierrec/lz4/v4 v4.1.28 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.5 // indirect
|
github.com/prometheus/common v0.70.1 // indirect
|
||||||
github.com/prometheus/otlptranslator v1.0.0 // indirect
|
github.com/prometheus/otlptranslator v1.0.0 // indirect
|
||||||
github.com/prometheus/procfs v0.20.1 // indirect
|
github.com/prometheus/procfs v0.21.1 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
github.com/sirupsen/logrus v1.10.0 // indirect
|
||||||
github.com/sorairolake/lzip-go v0.3.8 // indirect
|
github.com/sorairolake/lzip-go v0.3.8 // indirect
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/stangelandcl/ppmd v0.1.1 // indirect
|
github.com/stangelandcl/ppmd v0.1.1 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
github.com/ulikunitz/xz v0.5.16 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0 // indirect
|
go.opentelemetry.io/contrib/bridges/prometheus v0.70.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.21.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.45.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.45.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect
|
go.opentelemetry.io/otel/exporters/prometheus v0.67.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0 // indirect
|
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.21.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 // indirect
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 // indirect
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 // indirect
|
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
|
||||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
|
||||||
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
|
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
|
||||||
golang.org/x/crypto v0.52.0 // indirect
|
golang.org/x/crypto v0.55.0 // indirect
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/time v0.15.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 // indirect
|
||||||
google.golang.org/grpc v1.81.1 // indirect
|
google.golang.org/grpc v1.83.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.12 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
307
go.sum
307
go.sum
@@ -10,16 +10,16 @@ github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
|
|||||||
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
|
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
|
||||||
github.com/alexliesenfeld/health v0.8.1 h1:wdE3vt+cbJotiR8DGDBZPKHDFoJbAoWEfQTcqrmedUg=
|
github.com/alexliesenfeld/health v0.8.1 h1:wdE3vt+cbJotiR8DGDBZPKHDFoJbAoWEfQTcqrmedUg=
|
||||||
github.com/alexliesenfeld/health v0.8.1/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc=
|
github.com/alexliesenfeld/health v0.8.1/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc=
|
||||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
||||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
|
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
|
||||||
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
|
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
|
||||||
github.com/bodgit/sevenzip v1.6.4 h1:iHiVJfxbrB6RF4X+snI2MpVgNBKmVfGaTqZGNlMQIU0=
|
github.com/bodgit/sevenzip v1.6.5 h1:7H7BxgmeX0j6UX42lH+KXQ92WgMQJ49DoocFdfHbCng=
|
||||||
github.com/bodgit/sevenzip v1.6.4/go.mod h1:ZtNi5KNgHXeXg1G7WiF0IWSuFE2eG6lt/cTGlvuirO0=
|
github.com/bodgit/sevenzip v1.6.5/go.mod h1:GhuB6Lq1xCpP1sps+horjZ8lgiKPJcy2zUX3prla9wc=
|
||||||
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
|
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
|
||||||
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
|
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
@@ -42,18 +42,18 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
|||||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
|
||||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI=
|
github.com/cucumber/gherkin/go/v42 v42.0.1 h1:ao9TVJmBb8uNLEcjMDFbhsoL3yC7gMpRLHgP1ZGfFWA=
|
||||||
github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0=
|
github.com/cucumber/gherkin/go/v42 v42.0.1/go.mod h1:CsaumaO2dR9XvBc6ZyiGLMhWCKtTRDxgoxqJigSjSSg=
|
||||||
github.com/cucumber/godog v0.15.1 h1:rb/6oHDdvVZKS66hrhpjFQFHjthFSrQBCOI1LwshNTI=
|
github.com/cucumber/godog v0.16.0 h1:ezQbgItuWqZrjPUQwLJ3muwIlvzXBOfZso5QZfG7efE=
|
||||||
github.com/cucumber/godog v0.15.1/go.mod h1:qju+SQDewOljHuq9NSM66s0xEhogx0q30flfxL4WUk8=
|
github.com/cucumber/godog v0.16.0/go.mod h1:EDUX9yCqANK+GpbftMDeu61sUDtdLuo1JJgXD2n3bbM=
|
||||||
github.com/cucumber/messages/go/v21 v21.0.1 h1:wzA0LxwjlWQYZd32VTlAVDTkW6inOFmSM+RuOwHZiMI=
|
github.com/cucumber/messages/go/v34 v34.2.1 h1:qBPEl+HhNJuRX8Kjaw1Pm60KOODZf2/4WQAi0SR/neE=
|
||||||
github.com/cucumber/messages/go/v21 v21.0.1/go.mod h1:zheH/2HS9JLVFukdrsPWoPdmUtmYQAQPLk7w5vWsk5s=
|
github.com/cucumber/messages/go/v34 v34.2.1/go.mod h1:LYUPjqlTS1kS0pdkdf6sS5uirnjwiIzEGyXPezXNhL8=
|
||||||
github.com/cucumber/messages/go/v22 v22.0.0/go.mod h1:aZipXTKc0JnjCsXrJnuZpWhtay93k7Rn3Dee7iyPJjs=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -61,24 +61,26 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
|
|||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M=
|
||||||
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
|
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
|
||||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
|
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
|
||||||
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
|
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
|
||||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
|
||||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
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 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
||||||
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 h1:NZBJxCpbHS1gzS6xAmyxbJznosZIIPk9IB42v62UvKA=
|
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
@@ -90,14 +92,10 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
|||||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||||
github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
|
||||||
github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
|
||||||
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
|
|
||||||
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df h1:Mwihr/o+v4L5h56rwHLOE20+hh7Okhwno5BHz3zDuao=
|
github.com/gomarkdown/markdown v0.0.0-20260725000948-8435af3f5984 h1:6DE2PprLLZelQJu7AeebZLxjcENwC1plRZHDvd5uMCU=
|
||||||
github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
github.com/gomarkdown/markdown v0.0.0-20260725000948-8435af3f5984/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
@@ -105,16 +103,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
|
||||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||||
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||||
github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
|
||||||
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
|
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
|
||||||
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||||
github.com/hashicorp/go-memdb v1.3.4/go.mod h1:uBTr1oQbtuMgd1SSGoR8YV27eT3sBHbYiNm53bMpgSg=
|
|
||||||
github.com/hashicorp/go-memdb v1.3.5 h1:b3taDMxCBCBVgyRrS1AZVHO14ubMYZB++QpNhBg+Nyo=
|
github.com/hashicorp/go-memdb v1.3.5 h1:b3taDMxCBCBVgyRrS1AZVHO14ubMYZB++QpNhBg+Nyo=
|
||||||
github.com/hashicorp/go-memdb v1.3.5/go.mod h1:8IVKKBkVe+fxFgdFOYxzQQNjz+sWCyHCdIC/+5+Vy1Y=
|
github.com/hashicorp/go-memdb v1.3.5/go.mod h1:8IVKKBkVe+fxFgdFOYxzQQNjz+sWCyHCdIC/+5+Vy1Y=
|
||||||
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
|
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
|
||||||
@@ -128,102 +124,98 @@ github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iP
|
|||||||
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
|
||||||
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
|
||||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||||
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
||||||
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/labstack/echo/v4 v4.15.2 h1:nnh2sCzGCVYnU+wCisMPiYapEg/QVo/gcI9ePKg5/T4=
|
github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs=
|
||||||
github.com/labstack/echo/v4 v4.15.2/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI=
|
github.com/labstack/echo/v4 v4.15.4/go.mod h1:CuMetKIRwsuO/qlAgMq+KTAalwGoB/h4tC+yPdrTj1g=
|
||||||
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
|
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
|
||||||
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
|
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
|
||||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
|
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
|
||||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
github.com/magiconair/properties v1.18.11 h1:j5ozYZl0zCjG7ahMDH0GWIobOvvUzT0BdAguG0ViKy0=
|
||||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
github.com/magiconair/properties v1.18.11/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
|
github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
|
||||||
github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4=
|
github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0=
|
github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0=
|
||||||
github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
|
github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
|
||||||
github.com/minio/minlz v1.1.1 h1:OGmft1V6AnI/Wme332U6bhG54nxEan+VFgkD7lat4KM=
|
github.com/minio/minlz v1.2.0 h1:6IOBuiHg04QxvbFfgFLT/9sMaO/UhL7S+ApW1mK8q5A=
|
||||||
github.com/minio/minlz v1.1.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
|
github.com/minio/minlz v1.2.0/go.mod h1:Ls9H7nlkASeCcdl5thjVD5Eraj6z+zGa7xtq57jIKD4=
|
||||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
github.com/moby/go-archive v0.3.3 h1:OxxR9paxsluYi+zDUEXTTaIxtkK3viymW+Ka7vRhhME=
|
||||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
github.com/moby/go-archive v0.3.3/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE=
|
||||||
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
|
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
||||||
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||||
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
|
github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw=
|
||||||
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
|
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 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||||
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
github.com/moby/sys/mount v0.3.5 h1:eS3fsZTjHaBihwjp4/+5Z3jxqLXYsbwxqpVSfFv3M00=
|
||||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
github.com/moby/sys/mount v0.3.5/go.mod h1:WUQDO+/uCiCIkIztx8SrwIDVn2dtMFRBebRhpDFT71M=
|
||||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
|
||||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
|
||||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
|
||||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
|
||||||
|
github.com/moby/sys/user v0.4.1 h1:RgjRlaDKi/Xmyrz4t8lyzXT6v2ooFeO/7xtchmhVWE0=
|
||||||
|
github.com/moby/sys/user v0.4.1/go.mod h1:E9QsW5WRe1kUAf7kW8hXKwu1uhsZEAdPLYHYSDudF4Y=
|
||||||
|
github.com/moby/sys/userns v0.2.0 h1:nEtDtp7NCV/6dutSklNe8FrENPwFdc4mXnZqC/JWgXM=
|
||||||
|
github.com/moby/sys/userns v0.2.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nwaples/rardecode/v2 v2.2.3 h1:qaVuy3ChZDbAQZshPLjHeNJKF3Cru8uo9jmgveKIy2A=
|
github.com/nwaples/rardecode/v2 v2.3.0 h1:CtgyxWm8ClLcSh1u4M58fOz6lmeb/j4V7KpaEi/6UtM=
|
||||||
github.com/nwaples/rardecode/v2 v2.2.3/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
|
github.com/nwaples/rardecode/v2 v2.3.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
|
||||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||||
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
|
github.com/pierrec/lz4/v4 v4.1.28 h1:pPEPwRJ4kybBTfGt28q7lQsRJQHhC08axprdLD5Ppio=
|
||||||
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
github.com/pierrec/lz4/v4 v4.1.28/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
||||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
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.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
|
||||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
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 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
|
||||||
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
|
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
|
||||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM=
|
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
github.com/sirupsen/logrus v1.10.0 h1:T8MxJJXVZkfcC5zSRMRAg2F8+lxjmUCGGWPzFxO+Msc=
|
||||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
github.com/sirupsen/logrus v1.10.0/go.mod h1:FXZFonkDAnFozmO+5hGAFvB0Yg9/j2SIhA/QuIkP180=
|
||||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
|
||||||
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
|
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
|
||||||
github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU=
|
github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU=
|
||||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
|
||||||
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
|
||||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stangelandcl/ppmd v0.1.1 h1:c25QazhlWUn5nmR1QOzafKhQxBicAr7GGCKER2aJ8H8=
|
github.com/stangelandcl/ppmd v0.1.1 h1:c25QazhlWUn5nmR1QOzafKhQxBicAr7GGCKER2aJ8H8=
|
||||||
@@ -236,18 +228,17 @@ github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+Q
|
|||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
|
github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI=
|
||||||
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
|
github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
|
||||||
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||||
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
||||||
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||||
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
||||||
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
github.com/ulikunitz/xz v0.5.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0=
|
||||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
github.com/ulikunitz/xz v0.5.16/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
@@ -258,92 +249,94 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
|
|||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 h1:5RgvxieNq9tS3ewrV1vnODvbHPfKUIJcYtF9Cvz+6aQ=
|
go.opentelemetry.io/contrib/bridges/otelslog v0.20.0 h1:oEl2Pw/i4OQwhAuda2pAHFAcOMivA+Xa+iTccBfab/g=
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0/go.mod h1:iTBIdNwx/xmUhfgJs6+84S4dIK059811cO1eUBjKcHY=
|
go.opentelemetry.io/contrib/bridges/otelslog v0.20.0/go.mod h1:yMSQaiiq5dpfrSJCYLBcqFeJkFFI67seT4ngvx6jfVo=
|
||||||
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0 h1:saQoWg5845Q8TojpqeVStS7zGwVZ6bc5W2PJavTPiBM=
|
go.opentelemetry.io/contrib/bridges/prometheus v0.70.0 h1:qU2CqTGdlstwoVhu1WfjJJ3z2ntcNjTJO0ksTsFKzPI=
|
||||||
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0/go.mod h1:AAaS6xs5AyqMdR3Ir0nSWK+QudL2XM8Vbw5INzUxNc8=
|
go.opentelemetry.io/contrib/bridges/prometheus v0.70.0/go.mod h1:Ekh3I2XXfhdWkqbRq4PrivJS4BS/se7Er9ZsbK6YEtQ=
|
||||||
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0 h1:R3jsCoTIzv0BiYNhW0axyswn/6SMJ8xL1OuGxvni1Kw=
|
go.opentelemetry.io/contrib/exporters/autoexport v0.70.0 h1:wpCLEJ/4RHUadR11UOdznbmyyih5/OPYFcsehAh6PYI=
|
||||||
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0/go.mod h1:m07gqyr2QhQxKOKb5vqKCCBtLH3uqlNYR7PU/FISXVU=
|
go.opentelemetry.io/contrib/exporters/autoexport v0.70.0/go.mod h1:x7MbNOwoKV5Hj6uYMXQksHlQdTNOP3hoFPvqWISiu6s=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04=
|
||||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
|
||||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
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.21.0 h1:WseeVYf5dJZTsyPiyW5L14k5qsSibqXAMTSiFEDiWr0=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0/go.mod h1:SiLZnQS6Qk2eCpvr2CH/XMAOa64TWGXxEZJZCpD2Lmc=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.21.0 h1:fvNHGyo3CdRv/DQveXqhqBxnKTDyRaC5sMSQxilX/A0=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0/go.mod h1:MP4eemTiI9zC8fgg+DYynhYDYf3ba72S376TvP+Ye0Q=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.21.0/go.mod h1:zyGrjRKL2B/6+Jc/m4/otPoZqV2MY9ZjC/aBraRO7zc=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.45.0 h1:klTViGcsvLCd1xN3rZzfZ12NslC/OimbmR+k+A006RI=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug=
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.45.0/go.mod h1:jRsK04CWmXuY8A0O+wMpSf+t90RHZ53o5Qmxn2PQPfk=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA=
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.45.0 h1:pnxy6c/kvNBWdNNFzqpjuJLm9Hjhgk/Q0nY221rwuk0=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo=
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.45.0/go.mod h1:qw6YsFapotRwoDhXRZvljzaOvCQB7UfnafEJagpN2TA=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 h1:fG5MCxGz8+2VtrN/WgqSpJFctVz24gpxj8CxkKmc8Ww=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0/go.mod h1:BmAYTn+3ysbRe+IU2msxmf5Rx3g6DHvex+tWI3LdhYI=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 h1:QBajQ2SrwQijzHyZbQlPsuIzpl/ll8DY6wPWsajeGcI=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0/go.mod h1:08ZQLjrPLQ6R4kAXvuOvODEer5Yh4CoFvll5qB2BCI8=
|
||||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0 h1:vkrK8PAznv2NKt2r+kdu252ccGzkEqLc2aSXbQIALYQ=
|
go.opentelemetry.io/otel/exporters/prometheus v0.67.0 h1:7IefDa35e6V3NoiqIeLDMDxMFyZDk5qcoC0Ax4cC16E=
|
||||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0/go.mod h1:V/UB6D3vMF/UBOL5igAsAYnk1nG/bzYYTzvsB16cy7o=
|
go.opentelemetry.io/otel/exporters/prometheus v0.67.0/go.mod h1:nsPI1awTg5Vmg1YrommL2mVarVGlqc4yXOoKAkPRD0c=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 h1:aZfdmtI6QU/DAPD4b7YZ5zuJgewxO1EW9miOZklqleU=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.21.0 h1:2lpf4hnrasYIsUyEXwnTZq5lsxrMm4T2Bwb06IctAZQ=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0/go.mod h1:isNl10/Om5CBWu9jj8WOb2+tJLbCVXDgqwzCaJMnJ6w=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.21.0/go.mod h1:YWOW6h7jwApz9Pl76ie/izUsSPj0s2MdIlpqbPqaf3U=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0 h1:dm9iyzn6tioYZtwqaiBSU0TSI8Yu/8dTIbfG0+B49DY=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0/go.mod h1:xAvxYjYK28qvt+yu4BYZ/zMmAjwMXINXD6JiMyeB8iI=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs=
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 h1:lsA/S1bxgdbyFGkTj+3meEdJ6ADVU7QoFstV6MXgE68=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0=
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0/go.mod h1:L7u+MirGoB1bjeLH66+xDykF4RC8C3RN7lIFpBiewUo=
|
||||||
go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs=
|
go.opentelemetry.io/otel/log v0.21.0 h1:SLsVDGmtyBrdw8/a2Z0bOIxou/+bN4z56GebH7T0LvA=
|
||||||
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E=
|
go.opentelemetry.io/otel/log v0.21.0/go.mod h1:iReetQrZL9Wyg84cCkOoCmqDHS5RCFfyxC7J+r8fn8g=
|
||||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
|
||||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
|
||||||
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
|
go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns=
|
||||||
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
|
go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI=
|
||||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
|
||||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
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.21.0 h1:QsE7XSR0ktQdKmRKGnR+f1ObGF32WG+7MER/P9KgmYc=
|
||||||
go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU=
|
go.opentelemetry.io/otel/sdk/log v0.21.0/go.mod h1:m9mApjCoD2/1QuKCAptjv+BrG9WKOvQLVdNx+iBldTo=
|
||||||
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg=
|
go.opentelemetry.io/otel/sdk/log/logtest v0.21.0 h1:X+JBBgKlswCGYsmgL0CnoUUtlE//VB345c84jYAYkdQ=
|
||||||
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M=
|
go.opentelemetry.io/otel/sdk/log/logtest v0.21.0/go.mod h1:HD1575K8e6sIFBBDd5tZB3t9DlMytWXq9FuR+Y4rfjE=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
|
||||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
|
||||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
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.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
|
||||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||||
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
|
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
|
||||||
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
|
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
|
||||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
|
golang.org/x/sync v0.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-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754 h1:dWeMvEJ3JhYgqSCAHUZZJgMUyfniiiCvDc72x5EqJP0=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754/go.mod h1:q/3oV3jAi5vwelxsVAprMBC8BcM2zmNe+IjRGd+9/ks=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 h1:k5CJw9e5ONCcA/u0webKt092npXuY+KeGh3Q8NAVf0g=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
|
||||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
154
package-lock.json
generated
154
package-lock.json
generated
@@ -5,50 +5,106 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.9.6",
|
||||||
"prettier-plugin-gherkin": "^3.1.3",
|
"prettier-plugin-gherkin": "^4.0.0",
|
||||||
"prettier-plugin-sh": "^0.18.0"
|
"prettier-plugin-sh": "^0.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cucumber/gherkin": {
|
"node_modules/@cucumber/gherkin": {
|
||||||
"version": "32.2.0",
|
"version": "39.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-32.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-39.1.0.tgz",
|
||||||
"integrity": "sha512-X8xuVhSIqlUjxSRifRJ7t0TycVWyX58fygJH3wDNmHINLg9sYEkvQT0SO2G5YlRZnYc11TIFr4YPenscvdlBIw==",
|
"integrity": "sha512-pqmSO2bUWxJm3TbNrKXlDaHjL6c77+ez9kWmfCd9oRPeTRPEVH3spZvpAqdXYWOZYSNYwWFCAAeZ4RGpkauNoQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cucumber/messages": ">=19.1.4 <28"
|
"@cucumber/messages": ">=31.0.0 <33"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cucumber/messages": {
|
"node_modules/@cucumber/messages": {
|
||||||
"version": "27.2.0",
|
"version": "32.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-27.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-32.3.1.tgz",
|
||||||
"integrity": "sha512-f2o/HqKHgsqzFLdq6fAhfG1FNOQPdBdyMGpKwhb7hZqg0yZtx9BVqkTyuoNk83Fcvk3wjMVfouFXXHNEk4nddA==",
|
"integrity": "sha512-yNQq1KoXRYaEKrWMFmpUQX7TdeQuU9jeGgJAZ3dArTsC/T4NpJ6DnqaJIIgwPnz/wtQIQTNX7/h0rOuF5xY4qQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/uuid": "10.0.0",
|
|
||||||
"class-transformer": "0.5.1",
|
"class-transformer": "0.5.1",
|
||||||
"reflect-metadata": "0.2.2",
|
"reflect-metadata": "0.2.2"
|
||||||
"uuid": "11.0.5"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@reteps/dockerfmt": {
|
"node_modules/@reteps/dockerfmt": {
|
||||||
"version": "0.5.2",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.5.4.tgz",
|
||||||
"integrity": "sha512-Hbr7yen4fP5TxGM54ucXa4o5NwWXatJ6Bd9I8gp0PValYbI4Rug2Gu+rVv7K7o/efQc3F5ctqWJz47rYaa8zBw==",
|
"integrity": "sha512-HEGgXVVOb+JtGUSSzXl/XPKFIZjMDTUoHarCjaQdkY+cb5M9K/O3b5xm+x0IPIk3SfHurbc0bSgcFsQlzjitxA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"dockerfmt": "dist/launcher.js"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^v12.20.0 || ^14.13.0 || >=16.0.0"
|
"node": "^v12.20.0 || ^14.13.0 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@reteps/dockerfmt-darwin-arm64": "0.5.4",
|
||||||
|
"@reteps/dockerfmt-darwin-x64": "0.5.4",
|
||||||
|
"@reteps/dockerfmt-linux-arm64": "0.5.4",
|
||||||
|
"@reteps/dockerfmt-linux-x64": "0.5.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/uuid": {
|
"node_modules/@reteps/dockerfmt-darwin-arm64": {
|
||||||
"version": "10.0.0",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt-darwin-arm64/-/dockerfmt-darwin-arm64-0.5.4.tgz",
|
||||||
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
"integrity": "sha512-urMqV+dQyvVI8/WrXwClX9e1PEyS35wFdwJjpZYmL09AkV4Io5U1oam8UBKK7jZk0+YsdF88ay6e86Kn6DIyQg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@reteps/dockerfmt-darwin-x64": {
|
||||||
|
"version": "0.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt-darwin-x64/-/dockerfmt-darwin-x64-0.5.4.tgz",
|
||||||
|
"integrity": "sha512-fJORy6DFxbgDiMqxpLTPZlb5KUY0Vq0iR4NGnyKnuYZ9LdZUS508DK2kt/AJ87/jIKNV1qRG0JXG1Tc6xdvWjw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@reteps/dockerfmt-linux-arm64": {
|
||||||
|
"version": "0.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt-linux-arm64/-/dockerfmt-linux-arm64-0.5.4.tgz",
|
||||||
|
"integrity": "sha512-6pVakO06eXtDuvxy1Dnjs/gQyUoGGycle8PRSt5IFRwLi/AVaOQwfkfmW0WP8VH9wNUeti6BfJ3ksTn2G+XMxg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@reteps/dockerfmt-linux-x64": {
|
||||||
|
"version": "0.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt-linux-x64/-/dockerfmt-linux-x64-0.5.4.tgz",
|
||||||
|
"integrity": "sha512-OD6SIlUV1D4TgJoTui3FMBAZsGbTSPYsiT0BKhD6jMUcJb3GpFTa7dY9rL8rP9FUqfL7OTHVUGUOL4Rh64Olog==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"node_modules/class-transformer": {
|
"node_modules/class-transformer": {
|
||||||
"version": "0.5.1",
|
"version": "0.5.1",
|
||||||
@@ -58,9 +114,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/prettier": {
|
"node_modules/prettier": {
|
||||||
"version": "3.8.3",
|
"version": "3.9.6",
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
|
||||||
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -74,26 +130,26 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prettier-plugin-gherkin": {
|
"node_modules/prettier-plugin-gherkin": {
|
||||||
"version": "3.1.3",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/prettier-plugin-gherkin/-/prettier-plugin-gherkin-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/prettier-plugin-gherkin/-/prettier-plugin-gherkin-4.0.0.tgz",
|
||||||
"integrity": "sha512-w9uB413NlSi8ZQwpexyu+ttriJJ88eZLV0x88ZTkzkLZyHYEX5wrNtaCx/yFYviIu/tuwsBqDPM47VODnIV/hw==",
|
"integrity": "sha512-EBDwV1Ou9rG+seoh7jcwZ6sXXyHwsbUks5TeSyrlrLaTdz+/qYvnHuwY8rk7mb6/+M1ZGuY4/SiVc68ybwvJOA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cucumber/gherkin": "^32.0.0",
|
"@cucumber/gherkin": "^39.1.0",
|
||||||
"@cucumber/messages": "^27.2.0",
|
"@cucumber/messages": "^32.3.1",
|
||||||
"prettier": "^3.5.3"
|
"prettier": "^3.5.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prettier-plugin-sh": {
|
"node_modules/prettier-plugin-sh": {
|
||||||
"version": "0.18.1",
|
"version": "0.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.19.0.tgz",
|
||||||
"integrity": "sha512-uZmU22wBMevjh3rmCatNQqiEer2+5KLa0xYCBX6zQQUQkcNzVL+s6FbPKK6ZSUNUbQk6jMAcQHrYPvuL2W6ihQ==",
|
"integrity": "sha512-39VXFZH/cOGtcuu8aeSvqp/hhwomOR4QroZUj+jBz2cNb3os9s0sqFZSNlYts6jdtLLDU7D2YT3Z1+abtb7adQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@reteps/dockerfmt": "^0.5.1",
|
"@reteps/dockerfmt": "^0.5.4",
|
||||||
"sh-syntax": "^0.5.8"
|
"sh-syntax": "^0.6.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
@@ -113,41 +169,17 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/sh-syntax": {
|
"node_modules/sh-syntax": {
|
||||||
"version": "0.5.8",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.6.0.tgz",
|
||||||
"integrity": "sha512-JfVoxf4FxQI5qpsPbkHhZo+n6N9YMJobyl4oGEUBb/31oQYlgTjkXQD8PBiafS2UbWoxrTO0Z5PJUBXEPAG1Zw==",
|
"integrity": "sha512-52VK6z/cdZHv7UURjIcwfBUQZrAhIEEe0bY4lrkfypjnFIKsDZdD3Uaz/dBiw/sF8BeX0Mssv140s8EnrsJ9dQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.8.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/sh-syntax"
|
"url": "https://opencollective.com/sh-syntax"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"node_modules/tslib": {
|
|
||||||
"version": "2.8.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "0BSD"
|
|
||||||
},
|
|
||||||
"node_modules/uuid": {
|
|
||||||
"version": "11.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz",
|
|
||||||
"integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==",
|
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
|
||||||
"https://github.com/sponsors/broofa",
|
|
||||||
"https://github.com/sponsors/ctavan"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"uuid": "dist/esm/bin/uuid"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.9.6",
|
||||||
"prettier-plugin-gherkin": "^3.1.3",
|
"prettier-plugin-gherkin": "^4.0.0",
|
||||||
"prettier-plugin-sh": "^0.18.0"
|
"prettier-plugin-sh": "^0.19.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,13 +17,18 @@ import (
|
|||||||
"go.opentelemetry.io/otel/sdk/metric/exemplar"
|
"go.opentelemetry.io/otel/sdk/metric/exemplar"
|
||||||
"go.opentelemetry.io/otel/sdk/resource"
|
"go.opentelemetry.io/otel/sdk/resource"
|
||||||
"go.opentelemetry.io/otel/sdk/trace"
|
"go.opentelemetry.io/otel/sdk/trace"
|
||||||
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
|
semconv "go.opentelemetry.io/otel/semconv/v1.43.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
// buildResource assembles the OpenTelemetry resource shared by the tracer,
|
// buildResource assembles the OpenTelemetry resource shared by the tracer,
|
||||||
// meter, and logger providers. Detection is best-effort: a detector or merge
|
// 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
|
// failure is logged and the build proceeds with whatever was gathered, so a
|
||||||
// flaky environment never prevents telemetry from starting.
|
// flaky environment never prevents telemetry from starting.
|
||||||
|
//
|
||||||
|
// The semconv version imported here must match the one the SDK resource
|
||||||
|
// detectors use (go.opentelemetry.io/otel/sdk/resource). Drift makes
|
||||||
|
// [resource.Merge] fail with [resource.ErrSchemaURLConflict] and strips the
|
||||||
|
// schema URL off every exported signal.
|
||||||
func buildResource(ctx context.Context, logger *slog.Logger, serviceName, serviceVersion string) *resource.Resource {
|
func buildResource(ctx context.Context, logger *slog.Logger, serviceName, serviceVersion string) *resource.Resource {
|
||||||
base := resource.NewWithAttributes(
|
base := resource.NewWithAttributes(
|
||||||
semconv.SchemaURL,
|
semconv.SchemaURL,
|
||||||
@@ -55,9 +60,14 @@ func buildResource(ctx context.Context, logger *slog.Logger, serviceName, servic
|
|||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A schema URL conflict still yields a resource holding every attribute, only
|
||||||
|
// without a schema URL. Keep it: falling back to base would drop the host,
|
||||||
|
// OS, container, process, and OTEL_RESOURCE_ATTRIBUTES data.
|
||||||
merged, err := resource.Merge(detected, base)
|
merged, err := resource.Merge(detected, base)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.WarnContext(ctx, fmt.Sprintf("merge OpenTelemetry resource: %s", err))
|
logger.WarnContext(ctx, fmt.Sprintf("merge OpenTelemetry resource: %s", err))
|
||||||
|
}
|
||||||
|
if merged == nil {
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"go.opentelemetry.io/otel/sdk/metric/exemplar"
|
"go.opentelemetry.io/otel/sdk/metric/exemplar"
|
||||||
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||||
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
|
semconv "go.opentelemetry.io/otel/semconv/v1.43.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuildResource(t *testing.T) {
|
func TestBuildResource(t *testing.T) {
|
||||||
@@ -24,6 +24,13 @@ func TestBuildResource(t *testing.T) {
|
|||||||
values[string(kv.Key)] = kv.Value.AsString()
|
values[string(kv.Key)] = kv.Value.AsString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guards the semconv version pinned in buildResource against the one the SDK
|
||||||
|
// resource detectors use. Drift makes resource.Merge conflict and drops the
|
||||||
|
// schema URL from every exported signal.
|
||||||
|
if res.SchemaURL() != semconv.SchemaURL {
|
||||||
|
t.Errorf("resource schema URL = %q, want %q", res.SchemaURL(), semconv.SchemaURL)
|
||||||
|
}
|
||||||
|
|
||||||
if values[string(semconv.ServiceNameKey)] != "gotenberg" {
|
if values[string(semconv.ServiceNameKey)] != "gotenberg" {
|
||||||
t.Errorf("service.name = %q, want %q", values[string(semconv.ServiceNameKey)], "gotenberg")
|
t.Errorf("service.name = %q, want %q", values[string(semconv.ServiceNameKey)], "gotenberg")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ type PdfEngineMock struct {
|
|||||||
SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
|
SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
|
||||||
FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath string) error
|
FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath string) error
|
||||||
ConvertMock func(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
|
ConvertMock func(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
|
||||||
|
OptimizeImagesMock func(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error
|
||||||
ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
|
ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
|
||||||
PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
|
PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
|
||||||
WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
|
WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
|
||||||
@@ -80,6 +81,10 @@ func (engine *PdfEngineMock) Convert(ctx context.Context, logger *slog.Logger, f
|
|||||||
return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath)
|
return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (engine *PdfEngineMock) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
|
||||||
|
return engine.OptimizeImagesMock(ctx, logger, imageQuality, inputPath)
|
||||||
|
}
|
||||||
|
|
||||||
func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||||
return engine.ReadMetadataMock(ctx, logger, inputPath)
|
return engine.ReadMetadataMock(ctx, logger, inputPath)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
package gotenberg
|
package gotenberg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dlclark/regexp2"
|
"github.com/dlclark/regexp2"
|
||||||
|
"golang.org/x/net/http/httpproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrNonPublicIP indicates that an outbound URL targets an IP address that
|
// ErrNonPublicIP indicates that an outbound URL targets an IP address that
|
||||||
@@ -183,6 +188,12 @@ type OutboundDecision struct {
|
|||||||
// is stored.
|
// is stored.
|
||||||
type outboundDecisionKey struct{}
|
type outboundDecisionKey struct{}
|
||||||
|
|
||||||
|
// outboundProxiedKey is the context key under which [outboundRoundTripper]
|
||||||
|
// records that the environment proxy will carry this request, so that the
|
||||||
|
// dialer knows the address it receives is the proxy's rather than the
|
||||||
|
// destination's.
|
||||||
|
type outboundProxiedKey struct{}
|
||||||
|
|
||||||
// decideConfig carries optional settings for [DecideOutbound] and
|
// decideConfig carries optional settings for [DecideOutbound] and
|
||||||
// [FilterOutboundURL]. See [DecideOption] for how callers configure it.
|
// [FilterOutboundURL]. See [DecideOption] for how callers configure it.
|
||||||
type decideConfig struct {
|
type decideConfig struct {
|
||||||
@@ -327,8 +338,18 @@ func DecideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r
|
|||||||
return OutboundDecision{}, fmt.Errorf("'%s' targets a non-public address: %w", normalized, ErrFiltered)
|
return OutboundDecision{}, fmt.Errorf("'%s' targets a non-public address: %w", normalized, ErrFiltered)
|
||||||
case errors.Is(err, ErrPublicIP):
|
case errors.Is(err, ErrPublicIP):
|
||||||
return OutboundDecision{}, fmt.Errorf("'%s' targets a public address: %w", normalized, ErrFiltered)
|
return OutboundDecision{}, fmt.Errorf("'%s' targets a public address: %w", normalized, ErrFiltered)
|
||||||
default:
|
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
|
||||||
|
// A cancellation or timeout is not a policy decision; surface it
|
||||||
|
// as-is so callers do not report it as a filtered request.
|
||||||
return OutboundDecision{}, fmt.Errorf("validate '%s' host: %w", normalized, err)
|
return OutboundDecision{}, fmt.Errorf("validate '%s' host: %w", normalized, err)
|
||||||
|
default:
|
||||||
|
// The host could not be resolved, so its address class cannot be
|
||||||
|
// verified. Fail closed and treat it as filtered, the same as a
|
||||||
|
// host that resolves to a blocked address, so clients get a
|
||||||
|
// generic 403 rather than a 500. This also denies alternate IP
|
||||||
|
// encodings such as http://2130706433/ that the resolver rejects
|
||||||
|
// as a hostname but Chromium would read as a private IP.
|
||||||
|
return OutboundDecision{}, fmt.Errorf("validate '%s' host: %v: %w", normalized, err, ErrFiltered)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,6 +381,10 @@ type outboundRoundTripper struct {
|
|||||||
allowList []*regexp2.Regexp
|
allowList []*regexp2.Regexp
|
||||||
denyList []*regexp2.Regexp
|
denyList []*regexp2.Regexp
|
||||||
opts []DecideOption
|
opts []DecideOption
|
||||||
|
|
||||||
|
// proxyFunc mirrors the transport's own proxy resolution. It is nil unless
|
||||||
|
// the environment proxy is enabled.
|
||||||
|
proxyFunc func(*url.URL) (*url.URL, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RoundTrip validates req.URL and delegates to the base transport.
|
// RoundTrip validates req.URL and delegates to the base transport.
|
||||||
@@ -375,6 +400,18 @@ func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(req.Context(), outboundDecisionKey{}, decision)
|
ctx := context.WithValue(req.Context(), outboundDecisionKey{}, decision)
|
||||||
|
|
||||||
|
// A request the proxy will not carry is dialed directly, so it still gets
|
||||||
|
// pinned. Without this, enabling the environment proxy would silently drop
|
||||||
|
// DNS-rebinding protection for every NO_PROXY host, and for all traffic
|
||||||
|
// when no proxy variable is set at all.
|
||||||
|
if rt.proxyFunc != nil {
|
||||||
|
proxyURL, proxyErr := rt.proxyFunc(req.URL)
|
||||||
|
if proxyErr == nil && proxyURL != nil {
|
||||||
|
ctx = context.WithValue(ctx, outboundProxiedKey{}, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return rt.base.RoundTrip(req.WithContext(ctx))
|
return rt.base.RoundTrip(req.WithContext(ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,9 +426,47 @@ func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, er
|
|||||||
//
|
//
|
||||||
// The default posture is permissive; callers pass [WithDenyPrivateIPs]
|
// The default posture is permissive; callers pass [WithDenyPrivateIPs]
|
||||||
// or [WithDenyPublicIPs] to opt into IP-class rejection.
|
// or [WithDenyPublicIPs] to opt into IP-class rejection.
|
||||||
func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2.Regexp, opts ...DecideOption) *http.Client {
|
//
|
||||||
|
// When enableEnvironmentProxy is true, the client routes through the proxy
|
||||||
|
// defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables,
|
||||||
|
// including any credentials embedded in those URLs. Dial pinning does not apply
|
||||||
|
// to a hop the proxy carries, since the proxy owns DNS and egress there; a hop
|
||||||
|
// the proxy declines, such as a NO_PROXY host, is dialed directly and stays
|
||||||
|
// pinned. The URL allow/deny and IP-class validation runs either way. Callers
|
||||||
|
// gate this behind their module's opt-in flag. See
|
||||||
|
// https://github.com/gotenberg/gotenberg/issues/1592.
|
||||||
|
func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2.Regexp, enableEnvironmentProxy bool, opts ...DecideOption) *http.Client {
|
||||||
base := http.DefaultTransport.(*http.Transport).Clone()
|
base := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
base.DialContext = secureDialContext
|
|
||||||
|
var proxyFunc func(*url.URL) (*url.URL, error)
|
||||||
|
|
||||||
|
if enableEnvironmentProxy {
|
||||||
|
// Route through the operator's proxy (standard env vars, credentials
|
||||||
|
// included). httpproxy.FromEnvironment reads the environment now rather
|
||||||
|
// than caching it process-wide like http.ProxyFromEnvironment.
|
||||||
|
proxyFunc = httpproxy.FromEnvironment().ProxyFunc()
|
||||||
|
base.Proxy = func(req *http.Request) (*url.URL, error) {
|
||||||
|
return proxyFunc(req.URL)
|
||||||
|
}
|
||||||
|
// Only a hop the proxy actually carries skips pinning: there the dial
|
||||||
|
// targets the proxy, not the destination, and the proxy owns DNS. A hop
|
||||||
|
// the proxy declines is dialed directly and stays pinned.
|
||||||
|
base.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
if proxied, _ := ctx.Value(outboundProxiedKey{}).(bool); proxied {
|
||||||
|
return outboundDialer.DialContext(ctx, network, addr)
|
||||||
|
}
|
||||||
|
return secureDialContext(ctx, network, addr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Default: ignore any proxy environment variables and pin the dial to
|
||||||
|
// the IPs resolved during validation, closing the DNS-rebinding
|
||||||
|
// window. Clearing Proxy is deliberate: the cloned default transport
|
||||||
|
// carries http.ProxyFromEnvironment, which combined with the pinned
|
||||||
|
// dialer would connect to the destination IP on the proxy's port.
|
||||||
|
base.Proxy = nil
|
||||||
|
base.DialContext = secureDialContext
|
||||||
|
}
|
||||||
|
|
||||||
return &http.Client{
|
return &http.Client{
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
Transport: &outboundRoundTripper{
|
Transport: &outboundRoundTripper{
|
||||||
@@ -399,10 +474,60 @@ func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2
|
|||||||
allowList: allowList,
|
allowList: allowList,
|
||||||
denyList: denyList,
|
denyList: denyList,
|
||||||
opts: opts,
|
opts: opts,
|
||||||
|
proxyFunc: proxyFunc,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// environmentProxyVariables are the variables golang.org/x/net/http/httpproxy
|
||||||
|
// reads, in the casing precedence it applies.
|
||||||
|
var environmentProxyVariables = []string{
|
||||||
|
"HTTP_PROXY", "http_proxy",
|
||||||
|
"HTTPS_PROXY", "https_proxy",
|
||||||
|
"ALL_PROXY", "all_proxy",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateEnvironmentProxyVariables checks that every proxy variable currently
|
||||||
|
// set can be parsed as a proxy URL.
|
||||||
|
//
|
||||||
|
// httpproxy discards a parse error and falls back to a direct connection, so an
|
||||||
|
// operator who mistypes a proxy URL would silently lose the egress path they
|
||||||
|
// meant to enforce. Modules exposing an environment proxy flag call this from
|
||||||
|
// their Validate so that startup fails loudly instead.
|
||||||
|
//
|
||||||
|
// Values are never included in the error: a proxy URL may carry credentials.
|
||||||
|
func ValidateEnvironmentProxyVariables() error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
for _, name := range environmentProxyVariables {
|
||||||
|
if os.Getenv(name) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isUsableProxyURL(os.Getenv(name)) {
|
||||||
|
err = errors.Join(err, fmt.Errorf("environment variable %s is not a usable proxy URL; unset it, or set it to a value like 'http://user:password@host:3128'", name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// isUsableProxyURL mirrors httpproxy's own parsing: a URL with a proxy scheme,
|
||||||
|
// or anything that becomes one once a scheme is prefixed.
|
||||||
|
func isUsableProxyURL(value string) bool {
|
||||||
|
proxyURL, err := url.Parse(value)
|
||||||
|
if err == nil {
|
||||||
|
switch proxyURL.Scheme {
|
||||||
|
case "http", "https", "socks5", "socks5h":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpproxy retries bare values such as "host:3128" with a scheme.
|
||||||
|
_, err = url.Parse("http://" + value)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
// secureDialContext consumes the [OutboundDecision] stashed in ctx by
|
// secureDialContext consumes the [OutboundDecision] stashed in ctx by
|
||||||
// [outboundRoundTripper]. When the decision is to bypass (allow-list
|
// [outboundRoundTripper]. When the decision is to bypass (allow-list
|
||||||
// match), it dials directly. When the decision contains pinned IPs, it
|
// match), it dials directly. When the decision contains pinned IPs, it
|
||||||
@@ -452,3 +577,110 @@ func DialPinned(ctx context.Context, network string, addrs []netip.Addr, port st
|
|||||||
}
|
}
|
||||||
return nil, lastErr
|
return nil, lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DialThroughProxy opens a TCP tunnel to target (a host:port) through the
|
||||||
|
// HTTP CONNECT proxy at proxyURL, authenticating with any credentials
|
||||||
|
// embedded in proxyURL. dialProxy dials the proxy's own address; callers pass
|
||||||
|
// a plain dialer. Chromium and soffice cannot authenticate to a proxy
|
||||||
|
// themselves, so Gotenberg performs the CONNECT handshake on their behalf.
|
||||||
|
// The returned connection carries the raw tunnel for the caller to splice
|
||||||
|
// with the client. See https://github.com/gotenberg/gotenberg/issues/1592.
|
||||||
|
func DialThroughProxy(ctx context.Context, proxyURL *url.URL, target string, dialProxy func(ctx context.Context, network, addr string) (net.Conn, error)) (net.Conn, error) {
|
||||||
|
conn, err := dialProxy(ctx, "tcp", proxyHostPort(proxyURL))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dial proxy: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if proxyURL.Scheme == "https" {
|
||||||
|
tlsConn := tls.Client(conn, &tls.Config{ServerName: proxyURL.Hostname()})
|
||||||
|
err = tlsConn.HandshakeContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, fmt.Errorf("TLS handshake with proxy: %w", err)
|
||||||
|
}
|
||||||
|
conn = tlsConn
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bound the CONNECT handshake by the request deadline; cleared once the
|
||||||
|
// tunnel is established so splicing manages its own lifetime.
|
||||||
|
if deadline, ok := ctx.Deadline(); ok {
|
||||||
|
_ = conn.SetDeadline(deadline)
|
||||||
|
}
|
||||||
|
|
||||||
|
connectReq := &http.Request{
|
||||||
|
Method: http.MethodConnect,
|
||||||
|
URL: &url.URL{Opaque: target},
|
||||||
|
Host: target,
|
||||||
|
Header: make(http.Header),
|
||||||
|
}
|
||||||
|
if user := proxyURL.User; user != nil {
|
||||||
|
password, _ := user.Password()
|
||||||
|
connectReq.Header.Set("Proxy-Authorization", proxyAuthHeader(user.Username(), password))
|
||||||
|
}
|
||||||
|
|
||||||
|
err = connectReq.Write(conn)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, fmt.Errorf("write CONNECT to proxy: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
br := bufio.NewReader(conn)
|
||||||
|
resp, err := http.ReadResponse(br, connectReq)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, fmt.Errorf("read CONNECT response from proxy: %w", err)
|
||||||
|
}
|
||||||
|
// A CONNECT response carries no body; discard defensively.
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, fmt.Errorf("proxy refused CONNECT to %q with status %d", target, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = conn.SetDeadline(time.Time{})
|
||||||
|
|
||||||
|
// The reader may hold bytes the proxy sent right after the response;
|
||||||
|
// overlay it so those tunnel bytes are not lost when splicing.
|
||||||
|
return &bufferedConn{Conn: conn, r: br}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxyHostPort returns proxyURL's host:port, defaulting the port from the
|
||||||
|
// scheme when the URL omits it.
|
||||||
|
func proxyHostPort(proxyURL *url.URL) string {
|
||||||
|
port := proxyURL.Port()
|
||||||
|
if port == "" {
|
||||||
|
port = "80"
|
||||||
|
if proxyURL.Scheme == "https" {
|
||||||
|
port = "443"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return net.JoinHostPort(proxyURL.Hostname(), port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxyAuthHeader builds a Basic Proxy-Authorization header value.
|
||||||
|
func proxyAuthHeader(username, password string) string {
|
||||||
|
return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))
|
||||||
|
}
|
||||||
|
|
||||||
|
// bufferedConn overlays a [bufio.Reader] on a [net.Conn] so that bytes
|
||||||
|
// buffered while reading a proxy's CONNECT response are not lost when the
|
||||||
|
// tunnel is spliced.
|
||||||
|
type bufferedConn struct {
|
||||||
|
net.Conn
|
||||||
|
r *bufio.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *bufferedConn) Read(b []byte) (int, error) {
|
||||||
|
return c.r.Read(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseWrite half-closes the underlying connection. Embedding [net.Conn] hides
|
||||||
|
// the method, so a CONNECT splice over this connection could never signal EOF
|
||||||
|
// to the upstream and both sides waited for the other until a timeout.
|
||||||
|
func (c *bufferedConn) CloseWrite() error {
|
||||||
|
cw, ok := c.Conn.(interface{ CloseWrite() error })
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("underlying %T does not support half-close", c.Conn)
|
||||||
|
}
|
||||||
|
return cw.CloseWrite()
|
||||||
|
}
|
||||||
|
|||||||
155
pkg/gotenberg/outbound_envproxy_test.go
Normal file
155
pkg/gotenberg/outbound_envproxy_test.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package gotenberg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateEnvironmentProxyVariables(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
env map[string]string
|
||||||
|
wantErr bool
|
||||||
|
// wantIn is a substring the error must name, so that an operator can
|
||||||
|
// find the offending variable.
|
||||||
|
wantIn string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nothing set",
|
||||||
|
env: map[string]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "well formed URL",
|
||||||
|
env: map[string]string{"HTTP_PROXY": "http://proxy.example.com:3128"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "credentials are accepted",
|
||||||
|
env: map[string]string{"HTTPS_PROXY": "http://user:password@proxy.example.com:3128"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare host and port is accepted, as httpproxy prefixes a scheme",
|
||||||
|
env: map[string]string{"HTTP_PROXY": "proxy.example.com:3128"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "socks5 is accepted",
|
||||||
|
env: map[string]string{"ALL_PROXY": "socks5://proxy.example.com:1080"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lowercase variables are checked too",
|
||||||
|
env: map[string]string{"http_proxy": "http://proxy.example.com:3128"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unparseable URL",
|
||||||
|
env: map[string]string{"HTTP_PROXY": "http://proxy.example.com:3128/%zz"},
|
||||||
|
wantErr: true,
|
||||||
|
wantIn: "HTTP_PROXY",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "the failing variable is named",
|
||||||
|
env: map[string]string{"HTTPS_PROXY": "://%zz"},
|
||||||
|
wantErr: true,
|
||||||
|
wantIn: "HTTPS_PROXY",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
for _, name := range environmentProxyVariables {
|
||||||
|
t.Setenv(name, "")
|
||||||
|
}
|
||||||
|
for name, value := range tc.env {
|
||||||
|
t.Setenv(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ValidateEnvironmentProxyVariables()
|
||||||
|
if tc.wantErr && err == nil {
|
||||||
|
t.Fatal("expected an error, got none")
|
||||||
|
}
|
||||||
|
if !tc.wantErr && err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if tc.wantIn != "" && !strings.Contains(err.Error(), tc.wantIn) {
|
||||||
|
t.Errorf("error %q does not name %q", err, tc.wantIn)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateEnvironmentProxyVariables_DoesNotLeakCredentials pins that a
|
||||||
|
// proxy URL, which may embed a password, never reaches the error text.
|
||||||
|
func TestValidateEnvironmentProxyVariables_DoesNotLeakCredentials(t *testing.T) {
|
||||||
|
for _, name := range environmentProxyVariables {
|
||||||
|
t.Setenv(name, "")
|
||||||
|
}
|
||||||
|
t.Setenv("HTTP_PROXY", "http://admin:hunter2@proxy.example.com:3128/%zz")
|
||||||
|
|
||||||
|
err := ValidateEnvironmentProxyVariables()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error, got none")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "hunter2") {
|
||||||
|
t.Errorf("error leaks the proxy password: %q", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "admin") {
|
||||||
|
t.Errorf("error leaks the proxy username: %q", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewOutboundHttpClient_EnvironmentProxyPinsDirectHops is the regression
|
||||||
|
// test for the dial-pinning gap: with the environment proxy enabled but no
|
||||||
|
// proxy applicable to the request, the dial must still go through the pinning
|
||||||
|
// dialer rather than a plain one.
|
||||||
|
//
|
||||||
|
// The request targets a hostname that only the stub resolver knows, so a plain
|
||||||
|
// dial would hand that unresolvable name to the OS and fail. Only a pinned dial,
|
||||||
|
// which substitutes the address resolved during validation, can connect.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1592.
|
||||||
|
func TestNewOutboundHttpClient_EnvironmentProxyPinsDirectHops(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
_, port, err := net.SplitHostPort(strings.TrimPrefix(srv.URL, "http://"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("split server address: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = "pinned-only.invalid"
|
||||||
|
|
||||||
|
// NO_PROXY covers the destination, so httpproxy declines it and the
|
||||||
|
// transport dials directly. That direct dial is the hop that used to lose
|
||||||
|
// pinning.
|
||||||
|
for _, name := range environmentProxyVariables {
|
||||||
|
t.Setenv(name, "")
|
||||||
|
}
|
||||||
|
t.Setenv("HTTP_PROXY", "http://proxy.invalid:3128")
|
||||||
|
t.Setenv("NO_PROXY", host)
|
||||||
|
|
||||||
|
withStubResolver(t, func(string) ([]netip.Addr, error) {
|
||||||
|
return []netip.Addr{netip.MustParseAddr("127.0.0.1")}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
client := NewOutboundHttpClient(0, nil, nil, true)
|
||||||
|
rt, ok := client.Transport.(*outboundRoundTripper)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("transport is %T, want *outboundRoundTripper", client.Transport)
|
||||||
|
}
|
||||||
|
if rt.proxyFunc == nil {
|
||||||
|
t.Fatal("proxyFunc is nil, want the environment proxy to be resolved per request")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Get("http://" + net.JoinHostPort(host, port))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET failed, so the direct hop was not pinned: %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
153
pkg/gotenberg/outbound_proxy_test.go
Normal file
153
pkg/gotenberg/outbound_proxy_test.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package gotenberg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// connectCapture records the CONNECT request a proxy stub received.
|
||||||
|
type connectCapture struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
method string
|
||||||
|
host string
|
||||||
|
auth string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *connectCapture) set(method, host, auth string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.method, c.host, c.auth = method, host, auth
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *connectCapture) get() (string, string, string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.method, c.host, c.auth
|
||||||
|
}
|
||||||
|
|
||||||
|
// startConnectProxyStub starts a raw TCP server that behaves like an HTTP
|
||||||
|
// CONNECT proxy: it reads the CONNECT request, records it, replies 200 with a
|
||||||
|
// greeting appended to the same write (to exercise buffered-byte handling),
|
||||||
|
// then echoes tunnel bytes back to the caller.
|
||||||
|
func startConnectProxyStub(t *testing.T, capture *connectCapture) string {
|
||||||
|
t.Helper()
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = l.Close() })
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
conn, err := l.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
|
br := bufio.NewReader(conn)
|
||||||
|
req, err := http.ReadRequest(br)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
capture.set(req.Method, req.Host, req.Header.Get("Proxy-Authorization"))
|
||||||
|
|
||||||
|
// The greeting rides along with the response so the client's CONNECT
|
||||||
|
// response parser buffers it; bufferedConn must not drop it.
|
||||||
|
_, _ = conn.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\nTUNNEL-HELLO"))
|
||||||
|
_, _ = io.Copy(conn, br)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return l.Addr().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDialThroughProxy(t *testing.T) {
|
||||||
|
capture := &connectCapture{}
|
||||||
|
addr := startConnectProxyStub(t, capture)
|
||||||
|
|
||||||
|
proxyURL := &url.URL{Scheme: "http", Host: addr, User: url.UserPassword("alice", "s3cr3t")}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
conn, err := DialThroughProxy(ctx, proxyURL, "example.com:443", func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
var d net.Dialer
|
||||||
|
return d.DialContext(ctx, network, addr)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DialThroughProxy: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
|
// The greeting buffered while reading the CONNECT response must survive.
|
||||||
|
greeting := make([]byte, len("TUNNEL-HELLO"))
|
||||||
|
_, err = io.ReadFull(conn, greeting)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read greeting: %v", err)
|
||||||
|
}
|
||||||
|
if string(greeting) != "TUNNEL-HELLO" {
|
||||||
|
t.Fatalf("greeting = %q, want TUNNEL-HELLO", greeting)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tunnel must round-trip bytes.
|
||||||
|
_, err = conn.Write([]byte("ping"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write to tunnel: %v", err)
|
||||||
|
}
|
||||||
|
echo := make([]byte, 4)
|
||||||
|
_, err = io.ReadFull(conn, echo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read echo: %v", err)
|
||||||
|
}
|
||||||
|
if string(echo) != "ping" {
|
||||||
|
t.Fatalf("echo = %q, want ping", echo)
|
||||||
|
}
|
||||||
|
|
||||||
|
method, host, auth := capture.get()
|
||||||
|
if method != http.MethodConnect {
|
||||||
|
t.Fatalf("proxy saw method %q, want CONNECT", method)
|
||||||
|
}
|
||||||
|
if host != "example.com:443" {
|
||||||
|
t.Fatalf("proxy saw target %q, want example.com:443", host)
|
||||||
|
}
|
||||||
|
wantAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("alice:s3cr3t"))
|
||||||
|
if auth != wantAuth {
|
||||||
|
t.Fatalf("proxy saw Proxy-Authorization %q, want %q", auth, wantAuth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDialThroughProxy_RefusedStatus(t *testing.T) {
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = l.Close() })
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
conn, err := l.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
br := bufio.NewReader(conn)
|
||||||
|
_, _ = http.ReadRequest(br)
|
||||||
|
_, _ = conn.Write([]byte("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n"))
|
||||||
|
}()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err = DialThroughProxy(ctx, &url.URL{Scheme: "http", Host: l.Addr().String()}, "example.com:443", func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
var d net.Dialer
|
||||||
|
return d.DialContext(ctx, network, addr)
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error when the proxy refuses CONNECT, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -311,6 +311,39 @@ func TestFilterOutboundURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDecideOutbound_UnresolvableHostFailsClosed(t *testing.T) {
|
||||||
|
withStubResolver(t, func(string) ([]netip.Addr, error) {
|
||||||
|
return nil, errors.New("no such host")
|
||||||
|
})
|
||||||
|
|
||||||
|
// An alternate IP encoding (decimal for 127.0.0.1) that the resolver
|
||||||
|
// rejects as a hostname must fail closed as filtered, not surface as a
|
||||||
|
// server error, so clients receive a generic 403.
|
||||||
|
_, err := DecideOutbound(context.Background(), "http://2130706433/", nil, nil, time.Now().Add(5*time.Second), WithDenyPrivateIPs(true))
|
||||||
|
if !errors.Is(err, ErrFiltered) {
|
||||||
|
t.Fatalf("expected ErrFiltered, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideOutbound_ResolverCancellationNotFiltered(t *testing.T) {
|
||||||
|
withStubResolver(t, func(string) ([]netip.Addr, error) {
|
||||||
|
return nil, context.Canceled
|
||||||
|
})
|
||||||
|
|
||||||
|
// A cancellation or timeout is not a policy decision and must not be
|
||||||
|
// reported as a filtered request.
|
||||||
|
_, err := DecideOutbound(context.Background(), "http://example.com/", nil, nil, time.Now().Add(5*time.Second), WithDenyPrivateIPs(true))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrFiltered) {
|
||||||
|
t.Fatalf("cancellation must not be filtered, got: %v", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("expected context.Canceled, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveAndCheckPublic_IPLiteralLoopback(t *testing.T) {
|
func TestResolveAndCheckPublic_IPLiteralLoopback(t *testing.T) {
|
||||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||||
t.Fatalf("unexpected DNS lookup for %q", host)
|
t.Fatalf("unexpected DNS lookup for %q", host)
|
||||||
|
|||||||
@@ -281,6 +281,12 @@ type PdfEngine interface {
|
|||||||
// PdfFormats. If no format, it does nothing.
|
// PdfFormats. If no format, it does nothing.
|
||||||
Convert(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
|
Convert(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
|
||||||
|
|
||||||
|
// OptimizeImages re-encodes the raster images of a PDF in place to shrink
|
||||||
|
// the file, leaving text, vectors, fonts and structure untouched.
|
||||||
|
// imageQuality is the JPEG quality (1 to 100) applied to each re-encoded
|
||||||
|
// image.
|
||||||
|
OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error
|
||||||
|
|
||||||
// ReadMetadata extracts the metadata of a given PDF file.
|
// ReadMetadata extracts the metadata of a given PDF file.
|
||||||
ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
|
ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ type Api struct {
|
|||||||
correlationIdHeader string
|
correlationIdHeader string
|
||||||
basicAuthUsername string
|
basicAuthUsername string
|
||||||
basicAuthPassword string
|
basicAuthPassword string
|
||||||
|
oidcEnabled bool
|
||||||
|
oidcIssuer string
|
||||||
|
oidcAudience string
|
||||||
|
oidcJwksUrl string
|
||||||
downloadFromCfg downloadFromConfig
|
downloadFromCfg downloadFromConfig
|
||||||
disableHealthCheckRouteTelemetry bool
|
disableHealthCheckRouteTelemetry bool
|
||||||
disableRootRouteTelemetry bool
|
disableRootRouteTelemetry bool
|
||||||
@@ -57,12 +61,13 @@ type Api struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type downloadFromConfig struct {
|
type downloadFromConfig struct {
|
||||||
allowList []*regexp2.Regexp
|
allowList []*regexp2.Regexp
|
||||||
denyList []*regexp2.Regexp
|
denyList []*regexp2.Regexp
|
||||||
denyPrivateIPs bool
|
denyPrivateIPs bool
|
||||||
denyPublicIPs bool
|
denyPublicIPs bool
|
||||||
maxRetry int
|
enableEnvironmentProxy bool
|
||||||
disable bool
|
maxRetry int
|
||||||
|
disable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Router is a module interface that adds routes to the [Api].
|
// Router is a module interface that adds routes to the [Api].
|
||||||
@@ -197,10 +202,15 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths")
|
fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths")
|
||||||
fs.String("api-correlation-id-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
|
fs.String("api-correlation-id-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
|
||||||
fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables")
|
fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables")
|
||||||
|
fs.Bool("api-enable-oidc-auth", false, "Enable OIDC bearer token authentication - mutually exclusive with basic authentication")
|
||||||
|
fs.String("api-oidc-issuer", "", "Set the OIDC issuer URL, e.g. https://tenant.example.com/ - the token 'iss' claim must match")
|
||||||
|
fs.String("api-oidc-audience", "", "Set the expected OIDC audience - the token 'aud' claim must contain it")
|
||||||
|
fs.String("api-oidc-jwks-url", "", "Set the OIDC JWKS URL - discovered from the issuer's well-known configuration when empty")
|
||||||
fs.StringSlice("api-download-from-allow-list", []string{}, "Set the allowed URLs for the download from feature using regular expressions - supports multiple values")
|
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.StringSlice("api-download-from-deny-list", []string{}, "Set the denied URLs for the download from feature using regular expressions - supports multiple values")
|
||||||
fs.Bool("api-download-from-deny-private-ips", false, "Reject downloadFrom URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted downloadFrom sources to mitigate SSRF against internal services")
|
fs.Bool("api-download-from-deny-private-ips", false, "Reject downloadFrom URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted downloadFrom sources to mitigate SSRF against internal services")
|
||||||
fs.Bool("api-download-from-deny-public-ips", false, "Reject downloadFrom URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent downloads from reaching the public internet")
|
fs.Bool("api-download-from-deny-public-ips", false, "Reject downloadFrom URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent downloads from reaching the public internet")
|
||||||
|
fs.Bool("api-download-from-enable-environment-proxy", false, "Route downloadFrom fetches through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials")
|
||||||
fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature")
|
fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature")
|
||||||
fs.Bool("api-disable-download-from", false, "Disable the download from feature")
|
fs.Bool("api-disable-download-from", false, "Disable the download from feature")
|
||||||
fs.Bool("api-disable-health-check-route-telemetry", true, "Disable telemetry for health check route")
|
fs.Bool("api-disable-health-check-route-telemetry", true, "Disable telemetry for health check route")
|
||||||
@@ -239,12 +249,13 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
|||||||
a.rootPath = flags.MustString("api-root-path")
|
a.rootPath = flags.MustString("api-root-path")
|
||||||
a.correlationIdHeader = flags.MustDeprecatedString("api-trace-header", "api-correlation-id-header")
|
a.correlationIdHeader = flags.MustDeprecatedString("api-trace-header", "api-correlation-id-header")
|
||||||
a.downloadFromCfg = downloadFromConfig{
|
a.downloadFromCfg = downloadFromConfig{
|
||||||
allowList: flags.MustRegexpSlice("api-download-from-allow-list"),
|
allowList: flags.MustRegexpSlice("api-download-from-allow-list"),
|
||||||
denyList: flags.MustRegexpSlice("api-download-from-deny-list"),
|
denyList: flags.MustRegexpSlice("api-download-from-deny-list"),
|
||||||
denyPrivateIPs: flags.MustBool("api-download-from-deny-private-ips"),
|
denyPrivateIPs: flags.MustBool("api-download-from-deny-private-ips"),
|
||||||
denyPublicIPs: flags.MustBool("api-download-from-deny-public-ips"),
|
denyPublicIPs: flags.MustBool("api-download-from-deny-public-ips"),
|
||||||
maxRetry: flags.MustInt("api-download-from-max-retry"),
|
enableEnvironmentProxy: flags.MustBool("api-download-from-enable-environment-proxy"),
|
||||||
disable: flags.MustBool("api-disable-download-from"),
|
maxRetry: flags.MustInt("api-download-from-max-retry"),
|
||||||
|
disable: flags.MustBool("api-disable-download-from"),
|
||||||
}
|
}
|
||||||
a.disableHealthCheckRouteTelemetry = flags.MustDeprecatedBool("api-disable-health-check-logging", "api-disable-health-check-route-telemetry")
|
a.disableHealthCheckRouteTelemetry = flags.MustDeprecatedBool("api-disable-health-check-logging", "api-disable-health-check-route-telemetry")
|
||||||
a.disableRootRouteTelemetry = flags.MustBool("api-disable-root-route-telemetry")
|
a.disableRootRouteTelemetry = flags.MustBool("api-disable-root-route-telemetry")
|
||||||
@@ -277,6 +288,15 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
|||||||
a.basicAuthPassword = basicAuthPassword
|
a.basicAuthPassword = basicAuthPassword
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enable OIDC auth? The flags are populated from their API_OIDC_* env vars
|
||||||
|
// by the CLI, so no manual environment lookup is needed here.
|
||||||
|
a.oidcEnabled = flags.MustBool("api-enable-oidc-auth")
|
||||||
|
if a.oidcEnabled {
|
||||||
|
a.oidcIssuer = flags.MustString("api-oidc-issuer")
|
||||||
|
a.oidcAudience = flags.MustString("api-oidc-audience")
|
||||||
|
a.oidcJwksUrl = flags.MustString("api-oidc-jwks-url")
|
||||||
|
}
|
||||||
|
|
||||||
// Get routes from modules.
|
// Get routes from modules.
|
||||||
mods, err := ctx.Modules(new(Router))
|
mods, err := ctx.Modules(new(Router))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -377,6 +397,13 @@ func (a *Api) Validate() error {
|
|||||||
err = errors.Join(err, errors.New("IP must be a valid IP address"))
|
err = errors.Join(err, errors.New("IP must be a valid IP address"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if a.downloadFromCfg.enableEnvironmentProxy {
|
||||||
|
proxyErr := gotenberg.ValidateEnvironmentProxyVariables()
|
||||||
|
if proxyErr != nil {
|
||||||
|
err = errors.Join(err, fmt.Errorf("--api-download-from-enable-environment-proxy is set: %w", proxyErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") {
|
if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") {
|
||||||
err = errors.Join(err,
|
err = errors.Join(err,
|
||||||
errors.New("both TLS certificate and key files must be set"),
|
errors.New("both TLS certificate and key files must be set"),
|
||||||
@@ -401,6 +428,25 @@ func (a *Api) Validate() error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if a.basicAuthUsername != "" && a.oidcEnabled {
|
||||||
|
err = errors.Join(err,
|
||||||
|
errors.New("basic authentication and OIDC authentication cannot both be enabled"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.oidcEnabled {
|
||||||
|
if a.oidcIssuer == "" {
|
||||||
|
err = errors.Join(err,
|
||||||
|
errors.New("OIDC issuer must not be empty when OIDC auth is enabled; set --api-oidc-issuer"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if a.oidcAudience == "" {
|
||||||
|
err = errors.Join(err,
|
||||||
|
errors.New("OIDC audience must not be empty when OIDC auth is enabled; set --api-oidc-audience"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -507,11 +553,18 @@ func (a *Api) Start() error {
|
|||||||
|
|
||||||
hardTimeout := a.timeout + (time.Duration(5) * time.Second)
|
hardTimeout := a.timeout + (time.Duration(5) * time.Second)
|
||||||
|
|
||||||
// Basic auth?
|
// Authentication?
|
||||||
var securityMiddleware echo.MiddlewareFunc
|
var securityMiddleware echo.MiddlewareFunc
|
||||||
if a.basicAuthUsername != "" {
|
switch {
|
||||||
|
case a.basicAuthUsername != "":
|
||||||
securityMiddleware = basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword)
|
securityMiddleware = basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword)
|
||||||
} else {
|
case a.oidcEnabled:
|
||||||
|
verifier, err := a.buildOidcVerifier()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build OIDC verifier: %w", err)
|
||||||
|
}
|
||||||
|
securityMiddleware = oidcAuthMiddleware(verifier)
|
||||||
|
default:
|
||||||
securityMiddleware = func(next echo.HandlerFunc) echo.HandlerFunc {
|
securityMiddleware = func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
return next(c)
|
return next(c)
|
||||||
|
|||||||
67
pkg/modules/api/api_test.go
Normal file
67
pkg/modules/api/api_test.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApi_Validate_Auth(t *testing.T) {
|
||||||
|
base := func() *Api {
|
||||||
|
return &Api{port: 3000, rootPath: "/", correlationIdHeader: "Gotenberg-Trace"}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
mutate func(*Api)
|
||||||
|
wantErr string // substring expected in the error, "" means no error
|
||||||
|
}{
|
||||||
|
{"no auth", func(*Api) {}, ""},
|
||||||
|
{"basic auth only", func(a *Api) { a.basicAuthUsername = "foo" }, ""},
|
||||||
|
{
|
||||||
|
"oidc auth valid",
|
||||||
|
func(a *Api) {
|
||||||
|
a.oidcEnabled = true
|
||||||
|
a.oidcIssuer = "https://tenant.example.com/"
|
||||||
|
a.oidcAudience = "gotenberg"
|
||||||
|
},
|
||||||
|
"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"basic and oidc are mutually exclusive",
|
||||||
|
func(a *Api) {
|
||||||
|
a.basicAuthUsername = "foo"
|
||||||
|
a.oidcEnabled = true
|
||||||
|
a.oidcIssuer = "https://tenant.example.com/"
|
||||||
|
a.oidcAudience = "gotenberg"
|
||||||
|
},
|
||||||
|
"cannot both be enabled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"oidc missing issuer",
|
||||||
|
func(a *Api) { a.oidcEnabled = true; a.oidcAudience = "gotenberg" },
|
||||||
|
"issuer must not be empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"oidc missing audience",
|
||||||
|
func(a *Api) { a.oidcEnabled = true; a.oidcIssuer = "https://tenant.example.com/" },
|
||||||
|
"audience must not be empty",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
a := base()
|
||||||
|
tc.mutate(a)
|
||||||
|
|
||||||
|
err := a.Validate()
|
||||||
|
|
||||||
|
if tc.wantErr == "" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||||
|
t.Fatalf("error = %v, want a substring %q", err, tc.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -178,6 +178,12 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
|
|
||||||
return nil, cancel, fmt.Errorf("get multipart form: %w", err)
|
return nil, cancel, fmt.Errorf("get multipart form: %w", err)
|
||||||
}
|
}
|
||||||
|
defer func() {
|
||||||
|
err := form.RemoveAll()
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorContext(context.Background(), fmt.Sprintf("remove multipart temporary files: %s", err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// This will ensure we do not exceed the body limit.
|
// This will ensure we do not exceed the body limit.
|
||||||
var formValuesSize int64
|
var formValuesSize int64
|
||||||
@@ -281,7 +287,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
}
|
}
|
||||||
|
|
||||||
client := &retryablehttp.Client{
|
client := &retryablehttp.Client{
|
||||||
HTTPClient: gotenberg.NewOutboundHttpClient(time.Until(deadline), downloadFromCfg.allowList, downloadFromCfg.denyList, ipOpts...),
|
HTTPClient: gotenberg.NewOutboundHttpClient(time.Until(deadline), downloadFromCfg.allowList, downloadFromCfg.denyList, downloadFromCfg.enableEnvironmentProxy, ipOpts...),
|
||||||
RetryMax: downloadFromCfg.maxRetry,
|
RetryMax: downloadFromCfg.maxRetry,
|
||||||
RetryWaitMin: time.Duration(1) * time.Second,
|
RetryWaitMin: time.Duration(1) * time.Second,
|
||||||
RetryWaitMax: time.Until(deadline),
|
RetryWaitMax: time.Until(deadline),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -73,6 +74,64 @@ func TestNewContext_Cancellation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewContext_RemovesMultipartTemporaryFiles(t *testing.T) {
|
||||||
|
body := new(bytes.Buffer)
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
part, err := writer.CreateFormFile("files", "input.odt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create multipart file: %v", err)
|
||||||
|
}
|
||||||
|
_, err = part.Write(bytes.Repeat([]byte("x"), 1024))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write multipart file: %v", err)
|
||||||
|
}
|
||||||
|
err = writer.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close multipart writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/forms/libreoffice/convert", body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
err = req.ParseMultipartForm(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse multipart form: %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = req.MultipartForm.RemoveAll()
|
||||||
|
}()
|
||||||
|
|
||||||
|
upload, err := req.MultipartForm.File["files"][0].Open()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open disk-backed multipart file: %v", err)
|
||||||
|
}
|
||||||
|
temporaryFile, ok := upload.(*os.File)
|
||||||
|
if !ok {
|
||||||
|
_ = upload.Close()
|
||||||
|
t.Fatal("multipart upload is not disk-backed")
|
||||||
|
}
|
||||||
|
temporaryPath := temporaryFile.Name()
|
||||||
|
err = temporaryFile.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close disk-backed multipart file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
echoCtx := echo.New().NewContext(req, httptest.NewRecorder())
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
||||||
|
downloadFromCfg := downloadFromConfig{disable: true}
|
||||||
|
|
||||||
|
_, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromCfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newContext returned error: %v", err)
|
||||||
|
}
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err = os.Stat(temporaryPath)
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("multipart temporary file still exists: %s", temporaryPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Concurrent downloadFrom entries must not race on the shared maps
|
// Concurrent downloadFrom entries must not race on the shared maps
|
||||||
// (ctx.files, ctx.diskToOriginal, ctx.filesByField). Run under -race
|
// (ctx.files, ctx.diskToOriginal, ctx.filesByField). Run under -race
|
||||||
// to catch the data race; without -race a sufficient number of entries
|
// to catch the data race; without -race a sufficient number of entries
|
||||||
|
|||||||
@@ -479,6 +479,51 @@ func (form *FormData) Stamp(target *string) *FormData {
|
|||||||
return form
|
return form
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stamps binds the absolute paths of every file uploaded with the "stamp"
|
||||||
|
// field name, in submission order. Unlike [FormData.Stamp], it keeps all of
|
||||||
|
// them so a route can apply several stamps in a single request.
|
||||||
|
func (form *FormData) Stamps(target *[]string) *FormData {
|
||||||
|
if form.errors != nil {
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
if paths, ok := form.filesByField[StampFormField]; ok {
|
||||||
|
*target = paths
|
||||||
|
}
|
||||||
|
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watermarks binds the absolute paths of every file uploaded with the
|
||||||
|
// "watermark" field name, in submission order. Unlike [FormData.Watermark], it
|
||||||
|
// keeps all of them so a route can apply several watermarks in a single request.
|
||||||
|
func (form *FormData) Watermarks(target *[]string) *FormData {
|
||||||
|
if form.errors != nil {
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
if paths, ok := form.filesByField[WatermarkFormField]; ok {
|
||||||
|
*target = paths
|
||||||
|
}
|
||||||
|
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strings binds every value submitted for key, in submission order. A field
|
||||||
|
// repeated in the multipart body (e.g. multiple "stampSource") contributes one
|
||||||
|
// entry per occurrence, which lets a route read parallel field arrays.
|
||||||
|
func (form *FormData) Strings(key string, target *[]string) *FormData {
|
||||||
|
if form.errors != nil {
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
if values, ok := form.values[key]; ok {
|
||||||
|
*target = values
|
||||||
|
}
|
||||||
|
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
// FacturXXml binds the absolute path of the uploaded Factur-X CII invoice
|
// FacturXXml binds the absolute path of the uploaded Factur-X CII invoice
|
||||||
// XML. Only a file uploaded with the "facturxXml" field name is included.
|
// XML. Only a file uploaded with the "facturxXml" field name is included.
|
||||||
func (form *FormData) FacturXXml(target *string) *FormData {
|
func (form *FormData) FacturXXml(target *string) *FormData {
|
||||||
|
|||||||
@@ -1837,3 +1837,61 @@ func TestFormData_paths_excludesFacturXXml(t *testing.T) {
|
|||||||
t.Errorf("expected only the non-Factur-X .xml document, got %+v", paths)
|
t.Errorf("expected only the non-Factur-X .xml document, got %+v", paths)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFormData_Strings(t *testing.T) {
|
||||||
|
form := &FormData{
|
||||||
|
values: map[string][]string{
|
||||||
|
"foo": {"a", "b", "c"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var got []string
|
||||||
|
form.Strings("foo", &got)
|
||||||
|
|
||||||
|
if want := []string{"a", "b", "c"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("expected %+v, got %+v", want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
var missing []string
|
||||||
|
form.Strings("bar", &missing)
|
||||||
|
if missing != nil {
|
||||||
|
t.Errorf("expected nil for a missing key, got %+v", missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormData_Stamps(t *testing.T) {
|
||||||
|
form := &FormData{
|
||||||
|
filesByField: map[string][]string{
|
||||||
|
StampFormField: {"/tmp/abc/a.png", "/tmp/abc/b.pdf"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var got []string
|
||||||
|
form.Stamps(&got)
|
||||||
|
|
||||||
|
if want := []string{"/tmp/abc/a.png", "/tmp/abc/b.pdf"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("expected %+v, got %+v", want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
empty := &FormData{}
|
||||||
|
var none []string
|
||||||
|
empty.Stamps(&none)
|
||||||
|
if none != nil {
|
||||||
|
t.Errorf("expected nil when no stamp file was uploaded, got %+v", none)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormData_Watermarks(t *testing.T) {
|
||||||
|
form := &FormData{
|
||||||
|
filesByField: map[string][]string{
|
||||||
|
WatermarkFormField: {"/tmp/abc/a.png"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var got []string
|
||||||
|
form.Watermarks(&got)
|
||||||
|
|
||||||
|
if want := []string{"/tmp/abc/a.png"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("expected %+v, got %+v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/labstack/echo/v4/middleware"
|
"github.com/labstack/echo/v4/middleware"
|
||||||
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
"go.opentelemetry.io/otel"
|
"go.opentelemetry.io/otel"
|
||||||
"go.opentelemetry.io/otel/attribute"
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.opentelemetry.io/otel/propagation"
|
"go.opentelemetry.io/otel/propagation"
|
||||||
@@ -87,13 +88,37 @@ func ParseError(err error) (int, string) {
|
|||||||
return http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)
|
return http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// statusClientClosedRequest is the non-standard 499 status (nginx convention)
|
||||||
|
// recorded when the client aborts the request before it completes. It keeps
|
||||||
|
// such outcomes out of the 5xx range in the access log and Prometheus metrics.
|
||||||
|
const statusClientClosedRequest = 499
|
||||||
|
|
||||||
|
// requestCanceled reports whether err is the result of the client aborting the
|
||||||
|
// request rather than a server-side failure. It requires both that err wraps
|
||||||
|
// [context.Canceled] and that the request context itself was canceled, so a
|
||||||
|
// context.Canceled originating elsewhere still surfaces as an internal error.
|
||||||
|
// A server-side timeout is [context.DeadlineExceeded], mapped to 503 by
|
||||||
|
// [ParseError], and is deliberately not treated as a client abort.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1627.
|
||||||
|
func requestCanceled(c echo.Context, err error) bool {
|
||||||
|
return errors.Is(err, context.Canceled) && errors.Is(c.Request().Context().Err(), context.Canceled)
|
||||||
|
}
|
||||||
|
|
||||||
// httpErrorHandler is the centralized HTTP error handler. It parses the error,
|
// httpErrorHandler is the centralized HTTP error handler. It parses the error,
|
||||||
// returns a response as "text/plain; charset=UTF-8".
|
// returns a response as "text/plain; charset=UTF-8".
|
||||||
func httpErrorHandler() echo.HTTPErrorHandler {
|
func httpErrorHandler() echo.HTTPErrorHandler {
|
||||||
return func(err error, c echo.Context) {
|
return func(err error, c echo.Context) {
|
||||||
logger := c.Get("logger").(*slog.Logger)
|
logger := c.Get("logger").(*slog.Logger)
|
||||||
status, message := ParseError(err)
|
|
||||||
|
|
||||||
|
if requestCanceled(c, err) {
|
||||||
|
// The client is gone, so writing a body would only fail and add
|
||||||
|
// noise. Record the status so the access log and metrics classify
|
||||||
|
// it as a client abort rather than an internal error.
|
||||||
|
c.Response().WriteHeader(statusClientClosedRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status, message := ParseError(err)
|
||||||
c.Response().Header().Add(echo.HeaderContentType, echo.MIMETextPlainCharsetUTF8)
|
c.Response().Header().Add(echo.HeaderContentType, echo.MIMETextPlainCharsetUTF8)
|
||||||
|
|
||||||
err = c.String(status, message)
|
err = c.String(status, message)
|
||||||
@@ -150,9 +175,16 @@ func outputFilenameMiddleware() echo.MiddlewareFunc {
|
|||||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
filename := c.Request().Header.Get("Gotenberg-Output-Filename")
|
filename := c.Request().Header.Get("Gotenberg-Output-Filename")
|
||||||
|
// Keep only the last path segment, so that a caller cannot name an
|
||||||
|
// output file after a path.
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/1227.
|
// See https://github.com/gotenberg/gotenberg/issues/1227.
|
||||||
|
//
|
||||||
|
// [filepath.Base] alone is not enough: on Linux it does not treat a
|
||||||
|
// backslash as a separator, and this value reaches archive entry
|
||||||
|
// names. Use the same sanitizer as the other caller-supplied
|
||||||
|
// filenames.
|
||||||
if filename != "" {
|
if filename != "" {
|
||||||
filename = filepath.Base(filename)
|
filename = sanitizeFilename(filename)
|
||||||
}
|
}
|
||||||
c.Set("outputFilename", filename)
|
c.Set("outputFilename", filename)
|
||||||
// Call the next middleware in the chain.
|
// Call the next middleware in the chain.
|
||||||
@@ -257,9 +289,15 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
|
|||||||
finishTime := time.Now()
|
finishTime := time.Now()
|
||||||
|
|
||||||
status := c.Response().Status
|
status := c.Response().Status
|
||||||
|
canceled := false
|
||||||
if err != nil {
|
if err != nil {
|
||||||
parsedStatus, _ := ParseError(err)
|
canceled = requestCanceled(c, err)
|
||||||
status = parsedStatus
|
if canceled {
|
||||||
|
status = statusClientClosedRequest
|
||||||
|
} else {
|
||||||
|
parsedStatus, _ := ParseError(err)
|
||||||
|
status = parsedStatus
|
||||||
|
}
|
||||||
|
|
||||||
span.SetAttributes(attribute.String("error", err.Error()))
|
span.SetAttributes(attribute.String("error", err.Error()))
|
||||||
c.Error(err)
|
c.Error(err)
|
||||||
@@ -287,10 +325,15 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
|
|||||||
With(slog.Int64("bytes_in", c.Request().ContentLength)).
|
With(slog.Int64("bytes_in", c.Request().ContentLength)).
|
||||||
With(slog.Int64("bytes_out", c.Response().Size))
|
With(slog.Int64("bytes_out", c.Response().Size))
|
||||||
|
|
||||||
if err != nil {
|
switch {
|
||||||
accessLogger.ErrorContext(ctx, err.Error())
|
case err == nil:
|
||||||
} else {
|
|
||||||
accessLogger.InfoContext(ctx, "request handled")
|
accessLogger.InfoContext(ctx, "request handled")
|
||||||
|
case canceled:
|
||||||
|
// A client abort is expected, not a server failure; keep it
|
||||||
|
// visible but out of the error stream.
|
||||||
|
accessLogger.InfoContext(ctx, err.Error())
|
||||||
|
default:
|
||||||
|
accessLogger.ErrorContext(ctx, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
additionalAttributes := []attribute.KeyValue{
|
additionalAttributes := []attribute.KeyValue{
|
||||||
@@ -327,6 +370,63 @@ func basicAuthMiddleware(username, password string) echo.MiddlewareFunc {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildOidcVerifier constructs an OIDC ID token verifier. When oidcJwksUrl is
|
||||||
|
// set, the keys are fetched from that URL lazily, so there is no network call at
|
||||||
|
// startup; otherwise the provider is discovered from its issuer, which does one.
|
||||||
|
// Both paths use an OTEL-instrumented HTTP client, so the JWKS and discovery
|
||||||
|
// fetches produce client spans.
|
||||||
|
func (a *Api) buildOidcVerifier() (*oidc.IDTokenVerifier, error) {
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
Transport: otelhttp.NewTransport(http.DefaultTransport),
|
||||||
|
}
|
||||||
|
ctx := oidc.ClientContext(context.Background(), httpClient)
|
||||||
|
|
||||||
|
cfg := &oidc.Config{
|
||||||
|
ClientID: a.oidcAudience,
|
||||||
|
SupportedSigningAlgs: []string{oidc.RS256, oidc.ES256},
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.oidcJwksUrl != "" {
|
||||||
|
keySet := oidc.NewRemoteKeySet(ctx, a.oidcJwksUrl)
|
||||||
|
return oidc.NewVerifier(a.oidcIssuer, keySet, cfg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, err := oidc.NewProvider(ctx, a.oidcIssuer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("discover OIDC provider '%s': %w", a.oidcIssuer, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider.Verifier(cfg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// oidcAuthMiddleware validates the Bearer token in the Authorization header with
|
||||||
|
// the OIDC verifier, which checks the signature against the provider's rotating
|
||||||
|
// JWKS and the issuer, audience and expiry claims. It answers 401 for a missing
|
||||||
|
// or invalid token, logging the underlying reason at debug level without leaking
|
||||||
|
// it to the client.
|
||||||
|
func oidcAuthMiddleware(verifier *oidc.IDTokenVerifier) echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
rawToken, ok := strings.CutPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
|
||||||
|
if !ok || rawToken == "" {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "a Bearer token is required in the Authorization header")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := verifier.Verify(c.Request().Context(), rawToken)
|
||||||
|
if err != nil {
|
||||||
|
if logger, ok := c.Get("logger").(*slog.Logger); ok && logger != nil {
|
||||||
|
logger.DebugContext(c.Request().Context(), "OIDC token verification failed", slog.Any("error", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "the Bearer token is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// contextMiddleware, middleware for "multipart/form-data" requests, sets the
|
// contextMiddleware, middleware for "multipart/form-data" requests, sets the
|
||||||
// [Context] and related context.CancelFunc in the [echo.Context] under
|
// [Context] and related context.CancelFunc in the [echo.Context] under
|
||||||
// "context" and "cancel". If the process is synchronous, it also handles the
|
// "context" and "cancel". If the process is synchronous, it also handles the
|
||||||
|
|||||||
@@ -1,15 +1,134 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc/oidctest"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestRequestCanceled pins the client-abort discriminator: only a
|
||||||
|
// context.Canceled that stems from the request context counts, so a server
|
||||||
|
// timeout or an unrelated cancellation still surfaces as an internal failure.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1627.
|
||||||
|
func TestRequestCanceled(t *testing.T) {
|
||||||
|
canceled, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
timedOut, cancelTimeout := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
|
||||||
|
defer cancelTimeout()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
reqCtx context.Context
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"client abort", canceled, context.Canceled, true},
|
||||||
|
{"wrapped client abort", canceled, fmt.Errorf("convert to PDF: %w", context.Canceled), true},
|
||||||
|
{"canceled error but live request", context.Background(), context.Canceled, false},
|
||||||
|
{"canceled request but unrelated error", canceled, errors.New("boom"), false},
|
||||||
|
{"server timeout is not a client abort", timedOut, context.DeadlineExceeded, false},
|
||||||
|
{"no error", canceled, nil, false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(tc.reqCtx)
|
||||||
|
c := echo.New().NewContext(req, httptest.NewRecorder())
|
||||||
|
if got := requestCanceled(c, tc.err); got != tc.want {
|
||||||
|
t.Fatalf("requestCanceled = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHttpErrorHandler_ClientClosedRequest ensures a client abort is recorded
|
||||||
|
// as 499 rather than 500, and that a genuine failure keeps its status.
|
||||||
|
func TestHttpErrorHandler_ClientClosedRequest(t *testing.T) {
|
||||||
|
canceled, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
reqCtx context.Context
|
||||||
|
err error
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{"client abort", canceled, fmt.Errorf("convert to PDF: %w", context.Canceled), statusClientClosedRequest},
|
||||||
|
{"internal failure", context.Background(), errors.New("boom"), http.StatusInternalServerError},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(tc.reqCtx)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := echo.New().NewContext(req, rec)
|
||||||
|
c.Set("logger", slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
|
httpErrorHandler()(tc.err, c)
|
||||||
|
|
||||||
|
if rec.Code != tc.wantStatus {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOutputFilenameMiddleware pins the sanitizing of the
|
||||||
|
// "Gotenberg-Output-Filename" header. The value reaches archive entry names and
|
||||||
|
// a Content-Disposition header, so a path separator must never survive it.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1227 and
|
||||||
|
// GHSA-hwc4-gmrw-5222.
|
||||||
|
func TestOutputFilenameMiddleware(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
header string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"no header", "", ""},
|
||||||
|
{"plain filename", "foo", "foo"},
|
||||||
|
{"POSIX path", "/tmp/foo", "foo"},
|
||||||
|
{"POSIX traversal", "../../../etc/passwd", "passwd"},
|
||||||
|
{"Windows traversal", `..\..\..\..\Windows\System32\evil`, "evil"},
|
||||||
|
{"rooted Windows path", `C:\Windows\Temp\evil`, "evil"},
|
||||||
|
{"mixed separators", `a/b\c`, "c"},
|
||||||
|
{"trailing separator", "/tmp/", ""},
|
||||||
|
{"bare dot dot", "..", ".."},
|
||||||
|
{"control characters", "fo\x01o\x7f", "foo"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
handler := outputFilenameMiddleware()(func(c echo.Context) error { return nil })
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||||
|
if tc.header != "" {
|
||||||
|
req.Header.Set("Gotenberg-Output-Filename", tc.header)
|
||||||
|
}
|
||||||
|
c := echo.New().NewContext(req, httptest.NewRecorder())
|
||||||
|
|
||||||
|
err := handler(c)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := c.Get("outputFilename").(string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("outputFilename is not set as a string")
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("outputFilename = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHardTimeoutMiddleware_MissingLoggerReturnsErrorInsteadOfPanicking(t *testing.T) {
|
func TestHardTimeoutMiddleware_MissingLoggerReturnsErrorInsteadOfPanicking(t *testing.T) {
|
||||||
mw := hardTimeoutMiddleware(100 * time.Millisecond)
|
mw := hardTimeoutMiddleware(100 * time.Millisecond)
|
||||||
handler := mw(func(c echo.Context) error { return nil })
|
handler := mw(func(c echo.Context) error { return nil })
|
||||||
@@ -37,3 +156,90 @@ func TestHardTimeoutMiddleware_MissingLoggerReturnsErrorInsteadOfPanicking(t *te
|
|||||||
t.Fatalf("error = %q, want a message mentioning logger", err)
|
t.Fatalf("error = %q, want a message mentioning logger", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOidcAuthMiddleware(t *testing.T) {
|
||||||
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyID = "test-key"
|
||||||
|
audience = "gotenberg"
|
||||||
|
)
|
||||||
|
|
||||||
|
oidcServer := &oidctest.Server{
|
||||||
|
PublicKeys: []oidctest.PublicKey{
|
||||||
|
{PublicKey: privateKey.Public(), KeyID: keyID, Algorithm: oidc.RS256},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(oidcServer)
|
||||||
|
defer srv.Close()
|
||||||
|
oidcServer.SetIssuer(srv.URL)
|
||||||
|
|
||||||
|
// Building through the module's own helper exercises the discovery path too.
|
||||||
|
a := &Api{oidcIssuer: srv.URL, oidcAudience: audience}
|
||||||
|
verifier, err := a.buildOidcVerifier()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build verifier: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := func(issuer, aud string, expiresIn time.Duration) string {
|
||||||
|
now := time.Now()
|
||||||
|
return fmt.Sprintf(`{"iss":%q,"aud":%q,"sub":"user","exp":%d,"iat":%d}`,
|
||||||
|
issuer, aud, now.Add(expiresIn).Unix(), now.Unix())
|
||||||
|
}
|
||||||
|
sign := func(claims string) string {
|
||||||
|
return oidctest.SignIDToken(privateKey, keyID, oidc.RS256, claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
otherKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate other key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
authHeader string
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{"valid token", "Bearer " + sign(claims(srv.URL, audience, time.Hour)), http.StatusOK},
|
||||||
|
{"missing header", "", http.StatusUnauthorized},
|
||||||
|
{"wrong scheme", "Basic Zm9vOmJhcg==", http.StatusUnauthorized},
|
||||||
|
{"empty bearer", "Bearer ", http.StatusUnauthorized},
|
||||||
|
{"malformed token", "Bearer not-a-jwt", http.StatusUnauthorized},
|
||||||
|
{"wrong issuer", "Bearer " + sign(claims("https://evil.example/", audience, time.Hour)), http.StatusUnauthorized},
|
||||||
|
{"wrong audience", "Bearer " + sign(claims(srv.URL, "someone-else", time.Hour)), http.StatusUnauthorized},
|
||||||
|
{"expired token", "Bearer " + sign(claims(srv.URL, audience, -time.Hour)), http.StatusUnauthorized},
|
||||||
|
{"unknown signing key", "Bearer " + oidctest.SignIDToken(otherKey, "unknown", oidc.RS256, claims(srv.URL, audience, time.Hour)), http.StatusUnauthorized},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
if tc.authHeader != "" {
|
||||||
|
req.Header.Set("Authorization", tc.authHeader)
|
||||||
|
}
|
||||||
|
c := echo.New().NewContext(req, httptest.NewRecorder())
|
||||||
|
|
||||||
|
handler := oidcAuthMiddleware(verifier)(func(c echo.Context) error {
|
||||||
|
return c.NoContent(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
err := handler(c)
|
||||||
|
|
||||||
|
if tc.wantStatus == http.StatusOK {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected the request to pass, got error: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var httpErr *echo.HTTPError
|
||||||
|
if !errors.As(err, &httpErr) {
|
||||||
|
t.Fatalf("expected an *echo.HTTPError, got %T (%v)", err, err)
|
||||||
|
}
|
||||||
|
if httpErr.Code != tc.wantStatus {
|
||||||
|
t.Fatalf("status = %d, want %d", httpErr.Code, tc.wantStatus)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type browserArguments struct {
|
|||||||
allowFileAccessFromFiles bool
|
allowFileAccessFromFiles bool
|
||||||
hostResolverRules string
|
hostResolverRules string
|
||||||
proxyServer string
|
proxyServer string
|
||||||
|
enableEnvironmentProxy bool
|
||||||
wsUrlReadTimeout time.Duration
|
wsUrlReadTimeout time.Duration
|
||||||
hyphenDataDirPath string
|
hyphenDataDirPath string
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ type browserArguments struct {
|
|||||||
denyPublicIPs bool
|
denyPublicIPs bool
|
||||||
clearCache bool
|
clearCache bool
|
||||||
clearCookies bool
|
clearCookies bool
|
||||||
|
clearStorage bool
|
||||||
disableJavaScript bool
|
disableJavaScript bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +60,14 @@ type chromiumBrowser struct {
|
|||||||
userProfileDirPath string
|
userProfileDirPath string
|
||||||
ctxMu sync.RWMutex
|
ctxMu sync.RWMutex
|
||||||
isStarted atomic.Bool
|
isStarted atomic.Bool
|
||||||
|
// startMu serializes Start calls. The supervisor's runWithDeadline
|
||||||
|
// abandons a Start goroutine when the request deadline expires while
|
||||||
|
// Chromium's startup handshake is still hanging; the abandoned goroutine
|
||||||
|
// keeps running, holding the resources it acquired (the pinning proxy).
|
||||||
|
// Serializing here prevents a second, overlapping Start from colliding
|
||||||
|
// with the in-flight one on the shared pinning proxy.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1599.
|
||||||
|
startMu sync.Mutex
|
||||||
|
|
||||||
arguments browserArguments
|
arguments browserArguments
|
||||||
fs *gotenberg.FileSystem
|
fs *gotenberg.FileSystem
|
||||||
@@ -69,7 +79,7 @@ func newChromiumBrowser(arguments browserArguments) browser {
|
|||||||
initialCtx: context.Background(),
|
initialCtx: context.Background(),
|
||||||
arguments: arguments,
|
arguments: arguments,
|
||||||
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
|
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
|
||||||
pinningProxy: newPinningProxy(arguments.allowList, arguments.denyList, arguments.denyPrivateIPs, arguments.denyPublicIPs),
|
pinningProxy: newPinningProxy(arguments.allowList, arguments.denyList, arguments.denyPrivateIPs, arguments.denyPublicIPs, arguments.enableEnvironmentProxy),
|
||||||
}
|
}
|
||||||
b.isStarted.Store(false)
|
b.isStarted.Store(false)
|
||||||
|
|
||||||
@@ -77,6 +87,19 @@ func newChromiumBrowser(arguments browserArguments) browser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
||||||
|
// Refuse to run while a previous Start is still in flight. That previous
|
||||||
|
// Start may be a goroutine the supervisor abandoned after the request
|
||||||
|
// deadline expired while the Chromium startup handshake was hanging; it
|
||||||
|
// still holds the pinning proxy it started. An abandoned goroutine keeps
|
||||||
|
// holding startMu until it unwinds (bounded by --chromium-start-timeout),
|
||||||
|
// so no overlapping Start can collide with it on the shared pinning proxy
|
||||||
|
// and latch Chromium into a permanent "pinning proxy already started"
|
||||||
|
// state. See https://github.com/gotenberg/gotenberg/issues/1599.
|
||||||
|
if !b.startMu.TryLock() {
|
||||||
|
return errors.New("browser start already in progress")
|
||||||
|
}
|
||||||
|
defer b.startMu.Unlock()
|
||||||
|
|
||||||
if b.isStarted.Load() {
|
if b.isStarted.Load() {
|
||||||
return errors.New("browser is already started")
|
return errors.New("browser is already started")
|
||||||
}
|
}
|
||||||
@@ -157,6 +180,28 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
|||||||
return fmt.Errorf("start pinning proxy: %w", err)
|
return fmt.Errorf("start pinning proxy: %w", err)
|
||||||
}
|
}
|
||||||
opts = append(opts, chromedp.ProxyServer(b.pinningProxy.URL()))
|
opts = append(opts, chromedp.ProxyServer(b.pinningProxy.URL()))
|
||||||
|
|
||||||
|
if b.arguments.denyPrivateIPs || b.arguments.denyPublicIPs {
|
||||||
|
// Chromium implicitly bypasses the proxy for loopback and
|
||||||
|
// link-local destinations. A WebSocket handshake is never surfaced
|
||||||
|
// as a fetch.EventRequestPaused, so listenForEventRequestPaused
|
||||||
|
// cannot filter it; the pinning proxy is the only layer that sees
|
||||||
|
// it. Left alone, a page could open a WebSocket to 127.0.0.1, ::1,
|
||||||
|
// localhost, or the link-local cloud metadata endpoint
|
||||||
|
// (169.254.169.254) and reach it unfiltered. "<-loopback>" removes
|
||||||
|
// the implicit bypass so those handshakes also traverse the pinning
|
||||||
|
// proxy and go through [gotenberg.DecideOutbound] like every other
|
||||||
|
// request.
|
||||||
|
//
|
||||||
|
// Gated on the IP-class policy: it is the control this closes, and
|
||||||
|
// under it loopback and link-local HTTP sub-resources are already
|
||||||
|
// blocked by listenForEventRequestPaused before they would reach
|
||||||
|
// the proxy, so this adds only the missing WebSocket coverage. When
|
||||||
|
// the policy is off, loopback is not restricted, and routing it
|
||||||
|
// through the proxy would merely change how an unreachable loopback
|
||||||
|
// sub-resource reports its failure.
|
||||||
|
opts = append(opts, chromedp.Flag("proxy-bypass-list", "<-loopback>"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/524.
|
// See https://github.com/gotenberg/gotenberg/issues/524.
|
||||||
@@ -178,7 +223,7 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
|||||||
if stopErr != nil {
|
if stopErr != nil {
|
||||||
logger.ErrorContext(context.Background(), fmt.Sprintf("stop pinning proxy after failed start: %s", stopErr))
|
logger.ErrorContext(context.Background(), fmt.Sprintf("stop pinning proxy after failed start: %s", stopErr))
|
||||||
}
|
}
|
||||||
return fmt.Errorf("run exec allocator: %w", err)
|
return fmt.Errorf("run exec allocator: %w; if Chromium is slow to start, raise --chromium-start-timeout (currently %s)", err, b.arguments.wsUrlReadTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
b.ctxMu.Lock()
|
b.ctxMu.Lock()
|
||||||
@@ -323,6 +368,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, out
|
|||||||
runtime.Enable(),
|
runtime.Enable(),
|
||||||
clearCacheActionFunc(logger, b.arguments.clearCache),
|
clearCacheActionFunc(logger, b.arguments.clearCache),
|
||||||
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
||||||
|
clearStorageActionFunc(logger, b.arguments.clearStorage, url),
|
||||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||||
setCookiesActionFunc(logger, options.Cookies),
|
setCookiesActionFunc(logger, options.Cookies),
|
||||||
userAgentOverride(logger, options.UserAgent),
|
userAgentOverride(logger, options.UserAgent),
|
||||||
@@ -349,6 +395,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, u
|
|||||||
runtime.Enable(),
|
runtime.Enable(),
|
||||||
clearCacheActionFunc(logger, b.arguments.clearCache),
|
clearCacheActionFunc(logger, b.arguments.clearCache),
|
||||||
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
||||||
|
clearStorageActionFunc(logger, b.arguments.clearStorage, url),
|
||||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||||
setCookiesActionFunc(logger, options.Cookies),
|
setCookiesActionFunc(logger, options.Cookies),
|
||||||
userAgentOverride(logger, options.UserAgent),
|
userAgentOverride(logger, options.UserAgent),
|
||||||
@@ -412,6 +459,17 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
|||||||
extraHttpHeaders: options.ExtraHttpHeaders,
|
extraHttpHeaders: options.ExtraHttpHeaders,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// WebSocket handshakes never surface as fetch.EventRequestPaused, so
|
||||||
|
// listenForEventRequestPaused above cannot filter them. Validate them
|
||||||
|
// against the same allow / deny lists and IP-class policy.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1011.
|
||||||
|
listenForEventWebSocketCreated(taskCtx, logger, eventWebSocketCreatedOptions{
|
||||||
|
allowList: b.arguments.allowList,
|
||||||
|
denyList: b.arguments.denyList,
|
||||||
|
denyPrivateIPs: b.arguments.denyPrivateIPs,
|
||||||
|
denyPublicIPs: b.arguments.denyPublicIPs,
|
||||||
|
})
|
||||||
|
|
||||||
var (
|
var (
|
||||||
invalidHttpStatusCode error
|
invalidHttpStatusCode error
|
||||||
invalidHttpStatusCodeMu sync.RWMutex
|
invalidHttpStatusCodeMu sync.RWMutex
|
||||||
|
|||||||
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,10 @@ var (
|
|||||||
// or undefined.
|
// or undefined.
|
||||||
ErrInvalidSelectorQuery = errors.New("invalid selector query")
|
ErrInvalidSelectorQuery = errors.New("invalid selector query")
|
||||||
|
|
||||||
|
// ErrScreenshotSelectorNotFound happens when the CSS selector of a
|
||||||
|
// screenshot matches no element with a rendered box.
|
||||||
|
ErrScreenshotSelectorNotFound = errors.New("screenshot selector not found")
|
||||||
|
|
||||||
// ErrRpccMessageTooLarge happens when the messages received by
|
// ErrRpccMessageTooLarge happens when the messages received by
|
||||||
// ChromeDevTools are larger than 100 MB.
|
// ChromeDevTools are larger than 100 MB.
|
||||||
ErrRpccMessageTooLarge = errors.New("rpcc message too large")
|
ErrRpccMessageTooLarge = errors.New("rpcc message too large")
|
||||||
@@ -300,7 +304,8 @@ type PdfOptions struct {
|
|||||||
PreferCssPageSize bool
|
PreferCssPageSize bool
|
||||||
|
|
||||||
// GenerateDocumentOutline defines whether the document outline should be
|
// GenerateDocumentOutline defines whether the document outline should be
|
||||||
// embedded into the PDF.
|
// embedded into the PDF. Chromium derives the outline from the tagged-PDF
|
||||||
|
// structure tree, so enabling this implies GenerateTaggedPdf.
|
||||||
GenerateDocumentOutline bool
|
GenerateDocumentOutline bool
|
||||||
|
|
||||||
// GenerateTaggedPdf defines whether to generate tagged (accessible)
|
// GenerateTaggedPdf defines whether to generate tagged (accessible)
|
||||||
@@ -346,6 +351,11 @@ type ScreenshotOptions struct {
|
|||||||
// dimensions.
|
// dimensions.
|
||||||
Clip bool
|
Clip bool
|
||||||
|
|
||||||
|
// Selector clips the screenshot to the bounding box of the first element
|
||||||
|
// matching this CSS selector. Empty captures the whole page. Takes
|
||||||
|
// precedence over Clip.
|
||||||
|
Selector string
|
||||||
|
|
||||||
// Format is the image compression format, either "png" or "jpeg" or
|
// Format is the image compression format, either "png" or "jpeg" or
|
||||||
// "webp".
|
// "webp".
|
||||||
Format string
|
Format string
|
||||||
@@ -369,6 +379,7 @@ func DefaultScreenshotOptions() ScreenshotOptions {
|
|||||||
Width: 800,
|
Width: 800,
|
||||||
Height: 600,
|
Height: 600,
|
||||||
Clip: false,
|
Clip: false,
|
||||||
|
Selector: "",
|
||||||
Format: "png",
|
Format: "png",
|
||||||
Quality: 100,
|
Quality: 100,
|
||||||
OptimizeForSpeed: false,
|
OptimizeForSpeed: false,
|
||||||
@@ -459,12 +470,14 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
fs.Bool("chromium-allow-file-access-from-files", false, "Allow file:// URIs to read other file:// URIs")
|
fs.Bool("chromium-allow-file-access-from-files", false, "Allow file:// URIs to read other file:// URIs")
|
||||||
fs.String("chromium-host-resolver-rules", "", "Set custom mappings to the host resolver")
|
fs.String("chromium-host-resolver-rules", "", "Set custom mappings to the host resolver")
|
||||||
fs.String("chromium-proxy-server", "", "Set the outbound proxy server; this switch only affects HTTP and HTTPS requests")
|
fs.String("chromium-proxy-server", "", "Set the outbound proxy server; this switch only affects HTTP and HTTPS requests")
|
||||||
|
fs.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-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.StringSlice("chromium-deny-list", []string{`^file:(?!//\/tmp/).*`}, "Set the denied URLs for Chromium using regular expressions - supports multiple values")
|
||||||
fs.Bool("chromium-deny-private-ips", false, "Reject URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted form input to mitigate SSRF against internal services")
|
fs.Bool("chromium-deny-private-ips", false, "Reject URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted form input to mitigate SSRF against internal services")
|
||||||
fs.Bool("chromium-deny-public-ips", false, "Reject URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent outbound traffic from leaving a private network")
|
fs.Bool("chromium-deny-public-ips", false, "Reject URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent outbound traffic from leaving a private network")
|
||||||
fs.Bool("chromium-clear-cache", false, "Clear Chromium cache between each conversion")
|
fs.Bool("chromium-clear-cache", false, "Clear Chromium cache between each conversion")
|
||||||
fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion")
|
fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion")
|
||||||
|
fs.Bool("chromium-clear-storage", false, "Clear Chromium local storage between each conversion (session storage is already isolated per conversion)")
|
||||||
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
|
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
|
||||||
fs.Bool("chromium-disable-routes", false, "Disable the routes")
|
fs.Bool("chromium-disable-routes", false, "Disable the routes")
|
||||||
|
|
||||||
@@ -506,6 +519,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
|||||||
allowFileAccessFromFiles: flags.MustBool("chromium-allow-file-access-from-files"),
|
allowFileAccessFromFiles: flags.MustBool("chromium-allow-file-access-from-files"),
|
||||||
hostResolverRules: flags.MustString("chromium-host-resolver-rules"),
|
hostResolverRules: flags.MustString("chromium-host-resolver-rules"),
|
||||||
proxyServer: flags.MustString("chromium-proxy-server"),
|
proxyServer: flags.MustString("chromium-proxy-server"),
|
||||||
|
enableEnvironmentProxy: flags.MustBool("chromium-enable-environment-proxy"),
|
||||||
wsUrlReadTimeout: flags.MustDuration("chromium-start-timeout"),
|
wsUrlReadTimeout: flags.MustDuration("chromium-start-timeout"),
|
||||||
hyphenDataDirPath: hyphenDataDirPath,
|
hyphenDataDirPath: hyphenDataDirPath,
|
||||||
|
|
||||||
@@ -515,6 +529,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
|||||||
denyPublicIPs: flags.MustBool("chromium-deny-public-ips"),
|
denyPublicIPs: flags.MustBool("chromium-deny-public-ips"),
|
||||||
clearCache: flags.MustBool("chromium-clear-cache"),
|
clearCache: flags.MustBool("chromium-clear-cache"),
|
||||||
clearCookies: flags.MustBool("chromium-clear-cookies"),
|
clearCookies: flags.MustBool("chromium-clear-cookies"),
|
||||||
|
clearStorage: flags.MustBool("chromium-clear-storage"),
|
||||||
disableJavaScript: flags.MustBool("chromium-disable-javascript"),
|
disableJavaScript: flags.MustBool("chromium-disable-javascript"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,6 +681,13 @@ func (mod *Chromium) Validate() error {
|
|||||||
return fmt.Errorf("chromium-max-concurrency must be between 1 and 6, got %d", mod.maxConcurrency)
|
return fmt.Errorf("chromium-max-concurrency must be between 1 and 6, got %d", mod.maxConcurrency)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if mod.args.enableEnvironmentProxy {
|
||||||
|
proxyErr := gotenberg.ValidateEnvironmentProxyVariables()
|
||||||
|
if proxyErr != nil {
|
||||||
|
return fmt.Errorf("--chromium-enable-environment-proxy is set: %w", proxyErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_, err := os.Stat(mod.args.binPath)
|
_, err := os.Stat(mod.args.binPath)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return fmt.Errorf("Chromium binary does not exist at %q; check the CHROMIUM_BIN_PATH environment variable: %w", mod.args.binPath, err)
|
return fmt.Errorf("Chromium binary does not exist at %q; check the CHROMIUM_BIN_PATH environment variable: %w", mod.args.binPath, err)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/chromedp/cdproto/cdp"
|
"github.com/chromedp/cdproto/cdp"
|
||||||
"github.com/chromedp/cdproto/fetch"
|
"github.com/chromedp/cdproto/fetch"
|
||||||
@@ -43,6 +44,54 @@ func listenForNetworkActivity(ctx context.Context, aggregate *networkAggregate)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type eventWebSocketCreatedOptions struct {
|
||||||
|
allowList, denyList []*regexp2.Regexp
|
||||||
|
denyPrivateIPs bool
|
||||||
|
denyPublicIPs bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// listenForEventWebSocketCreated validates the target of every WebSocket
|
||||||
|
// handshake against the same allow / deny lists and IP-class policy as
|
||||||
|
// [listenForEventRequestPaused]. Chromium never surfaces a WebSocket
|
||||||
|
// handshake as a fetch.EventRequestPaused, so without this listener a page
|
||||||
|
// could open a WebSocket to an address the outbound filter would otherwise
|
||||||
|
// block. See https://github.com/gotenberg/gotenberg/issues/1011.
|
||||||
|
//
|
||||||
|
// This listener records an operator-visible warning with the full ws:// URL.
|
||||||
|
// The connection itself is severed by the pinning proxy, which every
|
||||||
|
// WebSocket handshake traverses once the implicit loopback / link-local proxy
|
||||||
|
// bypass is removed (see the "<-loopback>" flag in browser.go). When the
|
||||||
|
// operator configures a custom proxy or host-resolver mappings, the pinning
|
||||||
|
// proxy is not started; the WebSocket then follows the operator's egress path
|
||||||
|
// and this warning is the remaining safeguard, since a WebSocket handshake
|
||||||
|
// cannot be aborted through the CDP Network domain.
|
||||||
|
func listenForEventWebSocketCreated(ctx context.Context, logger *slog.Logger, options eventWebSocketCreatedOptions) {
|
||||||
|
chromedp.ListenTarget(ctx, func(ev any) {
|
||||||
|
e, ok := ev.(*network.EventWebSocketCreated)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
logger.DebugContext(ctx, fmt.Sprintf("event EventWebSocketCreated fired for '%s'", e.URL))
|
||||||
|
|
||||||
|
deadline, ok := ctx.Deadline()
|
||||||
|
if !ok {
|
||||||
|
logger.ErrorContext(ctx, "context has no deadline, cannot filter WebSocket URL")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := gotenberg.FilterOutboundURL(ctx, e.URL, options.allowList, options.denyList, deadline,
|
||||||
|
gotenberg.WithDenyPrivateIPs(options.denyPrivateIPs),
|
||||||
|
gotenberg.WithDenyPublicIPs(options.denyPublicIPs),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnContext(ctx, err.Error())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type eventRequestPausedOptions struct {
|
type eventRequestPausedOptions struct {
|
||||||
allowList, denyList []*regexp2.Regexp
|
allowList, denyList []*regexp2.Regexp
|
||||||
denyPrivateIPs bool
|
denyPrivateIPs bool
|
||||||
@@ -62,6 +111,11 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
|
|||||||
logger.DebugContext(ctx, fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders))
|
logger.DebugContext(ctx, fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared by every scope match of this conversion, across all paused
|
||||||
|
// requests. Its lifetime is the conversion, as this function is called once
|
||||||
|
// per conversion with that conversion's context.
|
||||||
|
budget := newScopeMatchBudget(scopeMatchBudgetPerConversion)
|
||||||
|
|
||||||
chromedp.ListenTarget(ctx, func(ev any) {
|
chromedp.ListenTarget(ctx, func(ev any) {
|
||||||
if e, ok := ev.(*fetch.EventRequestPaused); ok {
|
if e, ok := ev.(*fetch.EventRequestPaused); ok {
|
||||||
go func() {
|
go func() {
|
||||||
@@ -127,6 +181,14 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
|
|||||||
// First, we have to check if at least one header has to be
|
// First, we have to check if at least one header has to be
|
||||||
// set for the current request.
|
// set for the current request.
|
||||||
for _, header := range options.extraHttpHeaders {
|
for _, header := range options.extraHttpHeaders {
|
||||||
|
// This goroutine outlives the response: nothing cancels an
|
||||||
|
// in-flight match, so stop as soon as the conversion is over.
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
if header.Scope == nil {
|
if header.Scope == nil {
|
||||||
// Non-scoped header.
|
// Non-scoped header.
|
||||||
logger.DebugContext(ctx, fmt.Sprintf("extra HTTP header '%s' will be set for request URL '%s'", header.Name, e.Request.URL))
|
logger.DebugContext(ctx, fmt.Sprintf("extra HTTP header '%s' will be set for request URL '%s'", header.Name, e.Request.URL))
|
||||||
@@ -134,7 +196,18 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !budget.tryAcquire() {
|
||||||
|
// Treat the remaining scoped headers as non-matching rather
|
||||||
|
// than spending more CPU on a request the client may already
|
||||||
|
// have given up on.
|
||||||
|
logger.WarnContext(ctx, fmt.Sprintf("scope matching budget of %s exhausted, extra HTTP header '%s' and any subsequent scoped header will not be set; simplify the 'scope' patterns or reduce the number of scoped headers", scopeMatchBudgetPerConversion, header.Name))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
matchStart := time.Now()
|
||||||
ok, err := header.Scope.MatchString(e.Request.URL)
|
ok, err := header.Scope.MatchString(e.Request.URL)
|
||||||
|
budget.consume(time.Since(matchStart))
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case err != nil:
|
case err != nil:
|
||||||
logger.ErrorContext(ctx, fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err))
|
logger.ErrorContext(ctx, fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err))
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dlclark/regexp2"
|
"github.com/dlclark/regexp2"
|
||||||
|
"golang.org/x/net/http/httpproxy"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
@@ -43,6 +45,14 @@ type pinningProxy struct {
|
|||||||
// allow-list opt-in). Tests may override it.
|
// allow-list opt-in). Tests may override it.
|
||||||
dialBypass func(ctx context.Context, network, addr string) (net.Conn, error)
|
dialBypass func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||||
|
|
||||||
|
// upstreamProxy resolves the upstream (corporate) proxy for a
|
||||||
|
// destination URL from the standard proxy environment variables, or
|
||||||
|
// returns a nil URL to connect directly. It is nil unless the operator
|
||||||
|
// opted into proxy-environment honoring. When set, the pinning proxy
|
||||||
|
// performs the authenticated proxy handshake that Chromium cannot. See
|
||||||
|
// https://github.com/gotenberg/gotenberg/issues/1592.
|
||||||
|
upstreamProxy func(*url.URL) (*url.URL, error)
|
||||||
|
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
server *http.Server
|
server *http.Server
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
@@ -57,8 +67,8 @@ type pinningProxy struct {
|
|||||||
// [gotenberg.DecideOutbound] on every request the proxy sees, so
|
// [gotenberg.DecideOutbound] on every request the proxy sees, so
|
||||||
// Chromium inherits whatever posture the operator selected. The
|
// Chromium inherits whatever posture the operator selected. The
|
||||||
// returned proxy is not yet listening; call Start.
|
// returned proxy is not yet listening; call Start.
|
||||||
func newPinningProxy(allowList, denyList []*regexp2.Regexp, denyPrivateIPs, denyPublicIPs bool) *pinningProxy {
|
func newPinningProxy(allowList, denyList []*regexp2.Regexp, denyPrivateIPs, denyPublicIPs, enableEnvironmentProxy bool) *pinningProxy {
|
||||||
return &pinningProxy{
|
p := &pinningProxy{
|
||||||
allowList: allowList,
|
allowList: allowList,
|
||||||
denyList: denyList,
|
denyList: denyList,
|
||||||
decide: func(ctx context.Context, rawURL string, allow, deny []*regexp2.Regexp, deadline time.Time) (gotenberg.OutboundDecision, error) {
|
decide: func(ctx context.Context, rawURL string, allow, deny []*regexp2.Regexp, deadline time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
@@ -73,6 +83,14 @@ func newPinningProxy(allowList, denyList []*regexp2.Regexp, denyPrivateIPs, deny
|
|||||||
return dialer.DialContext(ctx, network, addr)
|
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
|
// Start binds the proxy to 127.0.0.1 on an ephemeral port and serves in a
|
||||||
@@ -188,8 +206,24 @@ func (p *pinningProxy) handleConnect(w http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When the operator routes egress through an authenticated proxy,
|
||||||
|
// Chromium cannot supply the credentials itself, so the pinning proxy
|
||||||
|
// performs the CONNECT (and authentication) upstream. The decision above
|
||||||
|
// still gated the destination through the allow/deny and IP-class rules.
|
||||||
|
var proxyURL *url.URL
|
||||||
|
if p.upstreamProxy != nil {
|
||||||
|
proxyURL, err = p.upstreamProxy(&url.URL{Scheme: "https", Host: req.Host})
|
||||||
|
if err != nil {
|
||||||
|
p.logger.WarnContext(req.Context(), fmt.Sprintf("resolve upstream proxy for '%s': %s", req.Host, err))
|
||||||
|
http.Error(w, "upstream proxy error", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var upstream net.Conn
|
var upstream net.Conn
|
||||||
switch {
|
switch {
|
||||||
|
case proxyURL != nil:
|
||||||
|
upstream, err = p.dialThroughUpstreamProxy(req.Context(), proxyURL, req.Host)
|
||||||
case decision.Bypass:
|
case decision.Bypass:
|
||||||
upstream, err = p.dialBypass(req.Context(), "tcp", req.Host)
|
upstream, err = p.dialBypass(req.Context(), "tcp", req.Host)
|
||||||
case len(decision.Pinned) > 0:
|
case len(decision.Pinned) > 0:
|
||||||
@@ -275,17 +309,35 @@ func (p *pinningProxy) handleForward(w http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var proxyURL *url.URL
|
||||||
|
if p.upstreamProxy != nil {
|
||||||
|
proxyURL, err = p.upstreamProxy(req.URL)
|
||||||
|
if err != nil {
|
||||||
|
p.logger.WarnContext(req.Context(), fmt.Sprintf("resolve upstream proxy for '%s': %s", req.URL.Redacted(), err))
|
||||||
|
http.Error(w, "upstream proxy error", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
outReq := req.Clone(req.Context())
|
outReq := req.Clone(req.Context())
|
||||||
outReq.RequestURI = ""
|
outReq.RequestURI = ""
|
||||||
stripHopByHopHeaders(outReq.Header)
|
stripHopByHopHeaders(outReq.Header)
|
||||||
|
|
||||||
|
// Build a fresh transport per request. The decision contains the pinned
|
||||||
|
// IPs to dial; reusing a transport across requests would leak the
|
||||||
|
// decision's closure across unrelated targets.
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
// Build a fresh transport per request. The decision contains the
|
|
||||||
// pinned IPs to dial; reusing a transport across requests would
|
|
||||||
// leak the decision's closure across unrelated targets.
|
|
||||||
DisableKeepAlives: true,
|
DisableKeepAlives: true,
|
||||||
Proxy: nil,
|
}
|
||||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
if proxyURL != nil {
|
||||||
|
// The upstream proxy owns DNS and egress; Go adds Proxy-Authorization
|
||||||
|
// from the URL's credentials. The decision above already gated the
|
||||||
|
// destination, and dialBypass dials the proxy host directly.
|
||||||
|
transport.Proxy = http.ProxyURL(proxyURL)
|
||||||
|
transport.DialContext = p.dialBypass
|
||||||
|
} else {
|
||||||
|
transport.Proxy = nil
|
||||||
|
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
_, port, splitErr := net.SplitHostPort(addr)
|
_, port, splitErr := net.SplitHostPort(addr)
|
||||||
if splitErr != nil {
|
if splitErr != nil {
|
||||||
return nil, fmt.Errorf("split forward addr %q: %w", addr, splitErr)
|
return nil, fmt.Errorf("split forward addr %q: %w", addr, splitErr)
|
||||||
@@ -298,7 +350,7 @@ func (p *pinningProxy) handleForward(w http.ResponseWriter, req *http.Request) {
|
|||||||
default:
|
default:
|
||||||
return nil, errors.New("no pinned addresses and not bypassed")
|
return nil, errors.New("no pinned addresses and not bypassed")
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
defer transport.CloseIdleConnections()
|
defer transport.CloseIdleConnections()
|
||||||
|
|
||||||
@@ -364,3 +416,11 @@ func isClientCancellation(ctx context.Context, err error) bool {
|
|||||||
}
|
}
|
||||||
return ctx.Err() != nil
|
return ctx.Err() != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dialThroughUpstreamProxy tunnels to target through the upstream proxy,
|
||||||
|
// letting [gotenberg.DialThroughProxy] perform the authenticated CONNECT that
|
||||||
|
// Chromium cannot. dialBypass dials the proxy itself and is overridable in
|
||||||
|
// tests. See https://github.com/gotenberg/gotenberg/issues/1592.
|
||||||
|
func (p *pinningProxy) dialThroughUpstreamProxy(ctx context.Context, proxyURL *url.URL, target string) (net.Conn, error) {
|
||||||
|
return gotenberg.DialThroughProxy(ctx, proxyURL, target, p.dialBypass)
|
||||||
|
}
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ func TestPinningProxy_Forward_Pinned_Success(t *testing.T) {
|
|||||||
upstreamURL := mustParseURL(t, upstream.URL)
|
upstreamURL := mustParseURL(t, upstream.URL)
|
||||||
|
|
||||||
var decideCalls atomic.Int32
|
var decideCalls atomic.Int32
|
||||||
p := newPinningProxy(nil, nil, false, false)
|
p := newPinningProxy(nil, nil, false, false, false)
|
||||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
decideCalls.Add(1)
|
decideCalls.Add(1)
|
||||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||||
@@ -151,7 +151,7 @@ func TestPinningProxy_Forward_Pinned_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPinningProxy_Forward_BlockedByDecide(t *testing.T) {
|
func TestPinningProxy_Forward_BlockedByDecide(t *testing.T) {
|
||||||
p := newPinningProxy(nil, nil, false, false)
|
p := newPinningProxy(nil, nil, false, false, false)
|
||||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered)
|
return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered)
|
||||||
}
|
}
|
||||||
@@ -187,7 +187,7 @@ func TestPinningProxy_Forward_Bypass(t *testing.T) {
|
|||||||
upstreamURL := mustParseURL(t, upstream.URL)
|
upstreamURL := mustParseURL(t, upstream.URL)
|
||||||
|
|
||||||
var bypassCalls atomic.Int32
|
var bypassCalls atomic.Int32
|
||||||
p := newPinningProxy(nil, nil, false, false)
|
p := newPinningProxy(nil, nil, false, false, false)
|
||||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
return gotenberg.OutboundDecision{Bypass: true}, nil
|
return gotenberg.OutboundDecision{Bypass: true}, nil
|
||||||
}
|
}
|
||||||
@@ -236,7 +236,7 @@ func TestPinningProxy_Forward_StripsHopByHopHeaders(t *testing.T) {
|
|||||||
t.Cleanup(upstream.Close)
|
t.Cleanup(upstream.Close)
|
||||||
upstreamURL := mustParseURL(t, upstream.URL)
|
upstreamURL := mustParseURL(t, upstream.URL)
|
||||||
|
|
||||||
p := newPinningProxy(nil, nil, false, false)
|
p := newPinningProxy(nil, nil, false, false, false)
|
||||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||||
}
|
}
|
||||||
@@ -276,7 +276,7 @@ func TestPinningProxy_Forward_StripsHopByHopHeaders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPinningProxy_Forward_RejectsNonAbsoluteURL(t *testing.T) {
|
func TestPinningProxy_Forward_RejectsNonAbsoluteURL(t *testing.T) {
|
||||||
p := newPinningProxy(nil, nil, false, false)
|
p := newPinningProxy(nil, nil, false, false, false)
|
||||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
t.Fatal("decide must not be called for malformed proxy request")
|
t.Fatal("decide must not be called for malformed proxy request")
|
||||||
return gotenberg.OutboundDecision{}, nil
|
return gotenberg.OutboundDecision{}, nil
|
||||||
@@ -316,7 +316,7 @@ func TestPinningProxy_CONNECT_Pinned_Success(t *testing.T) {
|
|||||||
t.Cleanup(stop)
|
t.Cleanup(stop)
|
||||||
|
|
||||||
var decideCalls atomic.Int32
|
var decideCalls atomic.Int32
|
||||||
p := newPinningProxy(nil, nil, false, false)
|
p := newPinningProxy(nil, nil, false, false, false)
|
||||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
decideCalls.Add(1)
|
decideCalls.Add(1)
|
||||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||||
@@ -386,7 +386,7 @@ func TestPinningProxy_CONNECT_Pinned_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPinningProxy_CONNECT_BlockedByDecide(t *testing.T) {
|
func TestPinningProxy_CONNECT_BlockedByDecide(t *testing.T) {
|
||||||
p := newPinningProxy(nil, nil, false, false)
|
p := newPinningProxy(nil, nil, false, false, false)
|
||||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered)
|
return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered)
|
||||||
}
|
}
|
||||||
@@ -445,7 +445,7 @@ func TestPinningProxy_DNSRebind_SingleResolution(t *testing.T) {
|
|||||||
return gotenberg.OutboundDecision{}, fmt.Errorf("rebind lookup: %w", gotenberg.ErrFiltered)
|
return gotenberg.OutboundDecision{}, fmt.Errorf("rebind lookup: %w", gotenberg.ErrFiltered)
|
||||||
}
|
}
|
||||||
|
|
||||||
p := newPinningProxy(nil, nil, false, false)
|
p := newPinningProxy(nil, nil, false, false, false)
|
||||||
p.decide = stubDecide
|
p.decide = stubDecide
|
||||||
p.dialPinned = func(_ context.Context, network string, addrs []netip.Addr, _ string) (net.Conn, error) {
|
p.dialPinned = func(_ context.Context, network string, addrs []netip.Addr, _ string) (net.Conn, error) {
|
||||||
if len(addrs) != 1 || addrs[0].String() != "93.184.216.34" {
|
if len(addrs) != 1 || addrs[0].String() != "93.184.216.34" {
|
||||||
@@ -488,7 +488,7 @@ func TestPinningProxy_DNSRebind_SingleResolution(t *testing.T) {
|
|||||||
// [TestPinningProxy_CONNECT_BlockedByDecide].
|
// [TestPinningProxy_CONNECT_BlockedByDecide].
|
||||||
func TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug(t *testing.T) {
|
func TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug(t *testing.T) {
|
||||||
rec := &recordingHandler{}
|
rec := &recordingHandler{}
|
||||||
p := newPinningProxy(nil, nil, false, false)
|
p := newPinningProxy(nil, nil, false, false, false)
|
||||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
// Mimic the wrap chain produced by outbound.resolveHost when the
|
// Mimic the wrap chain produced by outbound.resolveHost when the
|
||||||
// DNS lookup is canceled mid-flight by Chromium hanging up.
|
// DNS lookup is canceled mid-flight by Chromium hanging up.
|
||||||
@@ -547,7 +547,7 @@ func TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug(t *testing.T) {
|
|||||||
// HTTP forward requests aborted by the client must also log at debug.
|
// HTTP forward requests aborted by the client must also log at debug.
|
||||||
func TestPinningProxy_Forward_ClientCancellation_LoggedAtDebug(t *testing.T) {
|
func TestPinningProxy_Forward_ClientCancellation_LoggedAtDebug(t *testing.T) {
|
||||||
rec := &recordingHandler{}
|
rec := &recordingHandler{}
|
||||||
p := newPinningProxy(nil, nil, false, false)
|
p := newPinningProxy(nil, nil, false, false, false)
|
||||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
return gotenberg.OutboundDecision{}, fmt.Errorf("validate host: %w", context.DeadlineExceeded)
|
return gotenberg.OutboundDecision{}, fmt.Errorf("validate host: %w", context.DeadlineExceeded)
|
||||||
}
|
}
|
||||||
@@ -596,7 +596,7 @@ func TestPinningProxy_Forward_ClientCancellation_LoggedAtDebug(t *testing.T) {
|
|||||||
// still surface at warn level so operators see real refusals.
|
// still surface at warn level so operators see real refusals.
|
||||||
func TestPinningProxy_PolicyDenial_LoggedAtWarn(t *testing.T) {
|
func TestPinningProxy_PolicyDenial_LoggedAtWarn(t *testing.T) {
|
||||||
rec := &recordingHandler{}
|
rec := &recordingHandler{}
|
||||||
p := newPinningProxy(nil, nil, false, false)
|
p := newPinningProxy(nil, nil, false, false, false)
|
||||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
return gotenberg.OutboundDecision{}, fmt.Errorf("denied: %w", gotenberg.ErrFiltered)
|
return gotenberg.OutboundDecision{}, fmt.Errorf("denied: %w", gotenberg.ErrFiltered)
|
||||||
}
|
}
|
||||||
@@ -652,7 +652,7 @@ func TestPinningProxy_PolicyDenial_LoggedAtWarn(t *testing.T) {
|
|||||||
// [TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn].
|
// [TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn].
|
||||||
func TestPinningProxy_CONNECT_DialCancellation_LoggedAtDebug(t *testing.T) {
|
func TestPinningProxy_CONNECT_DialCancellation_LoggedAtDebug(t *testing.T) {
|
||||||
rec := &recordingHandler{}
|
rec := &recordingHandler{}
|
||||||
p := newPinningProxy(nil, nil, false, false)
|
p := newPinningProxy(nil, nil, false, false, false)
|
||||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||||
}
|
}
|
||||||
@@ -713,7 +713,7 @@ func TestPinningProxy_CONNECT_DialCancellation_LoggedAtDebug(t *testing.T) {
|
|||||||
// must still warn so operators see real problems.
|
// must still warn so operators see real problems.
|
||||||
func TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn(t *testing.T) {
|
func TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn(t *testing.T) {
|
||||||
rec := &recordingHandler{}
|
rec := &recordingHandler{}
|
||||||
p := newPinningProxy(nil, nil, false, false)
|
p := newPinningProxy(nil, nil, false, false, false)
|
||||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||||
}
|
}
|
||||||
@@ -770,7 +770,7 @@ func TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn(t *testing.T) {
|
|||||||
// logs at debug, not warn. Genuine RoundTrip failures still warn.
|
// logs at debug, not warn. Genuine RoundTrip failures still warn.
|
||||||
func TestPinningProxy_Forward_RoundTripCancellation_LoggedAtDebug(t *testing.T) {
|
func TestPinningProxy_Forward_RoundTripCancellation_LoggedAtDebug(t *testing.T) {
|
||||||
rec := &recordingHandler{}
|
rec := &recordingHandler{}
|
||||||
p := newPinningProxy(nil, nil, false, false)
|
p := newPinningProxy(nil, nil, false, false, false)
|
||||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||||
}
|
}
|
||||||
@@ -868,7 +868,7 @@ func TestIsClientCancellation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPinningProxy_StartTwice(t *testing.T) {
|
func TestPinningProxy_StartTwice(t *testing.T) {
|
||||||
p := newPinningProxy(nil, nil, false, false)
|
p := newPinningProxy(nil, nil, false, false, false)
|
||||||
err := p.Start(testLogger())
|
err := p.Start(testLogger())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("first Start: %v", err)
|
t.Fatalf("first Start: %v", err)
|
||||||
@@ -882,7 +882,7 @@ func TestPinningProxy_StartTwice(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPinningProxy_StopIdempotent(t *testing.T) {
|
func TestPinningProxy_StopIdempotent(t *testing.T) {
|
||||||
p := newPinningProxy(nil, nil, false, false)
|
p := newPinningProxy(nil, nil, false, false, false)
|
||||||
// Stop on a never-started proxy is a no-op.
|
// Stop on a never-started proxy is a no-op.
|
||||||
if err := p.Stop(testLogger()); err != nil {
|
if err := p.Stop(testLogger()); err != nil {
|
||||||
t.Fatalf("Stop on never-started proxy: %v", err)
|
t.Fatalf("Stop on never-started proxy: %v", err)
|
||||||
|
|||||||
76
pkg/modules/chromium/pinning_proxy_upstream_test.go
Normal file
76
pkg/modules/chromium/pinning_proxy_upstream_test.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package chromium
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dlclark/regexp2"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPinningProxy_Forward_ThroughUpstreamProxy verifies that when the
|
||||||
|
// operator opts into proxy-environment honoring, a plain HTTP request is
|
||||||
|
// forwarded through the upstream (corporate) proxy with the credentials
|
||||||
|
// Chromium cannot supply. See https://github.com/gotenberg/gotenberg/issues/1592.
|
||||||
|
func TestPinningProxy_Forward_ThroughUpstreamProxy(t *testing.T) {
|
||||||
|
var gotAuth atomic.Value
|
||||||
|
gotAuth.Store("")
|
||||||
|
|
||||||
|
// Stand-in for the corporate proxy: an HTTP server that receives the
|
||||||
|
// forwarded request and records the injected Proxy-Authorization.
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotAuth.Store(r.Header.Get("Proxy-Authorization"))
|
||||||
|
_, _ = fmt.Fprint(w, "via-corporate-proxy")
|
||||||
|
}))
|
||||||
|
t.Cleanup(upstream.Close)
|
||||||
|
|
||||||
|
upstreamURL := mustParseURL(t, upstream.URL)
|
||||||
|
upstreamURL.User = url.UserPassword("bob", "pw")
|
||||||
|
|
||||||
|
p := newPinningProxy(nil, nil, false, false, true)
|
||||||
|
// Force every destination through our stub upstream proxy.
|
||||||
|
p.upstreamProxy = func(_ *url.URL) (*url.URL, error) { return upstreamURL, nil }
|
||||||
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
|
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||||
|
}
|
||||||
|
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||||
|
t.Fatal("dialPinned must not be called when routing through an upstream proxy")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
proxyURL := newProxyForTest(t, p)
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: &http.Transport{Proxy: http.ProxyURL(mustParseURL(t, proxyURL))},
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Get("http://example.com/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET via proxy: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read body: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != "via-corporate-proxy" {
|
||||||
|
t.Fatalf("body = %q, want via-corporate-proxy", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("bob:pw"))
|
||||||
|
if got := gotAuth.Load().(string); got != wantAuth {
|
||||||
|
t.Fatalf("upstream proxy saw Proxy-Authorization %q, want %q", got, wantAuth)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,20 @@ import (
|
|||||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Bounds on the scoped extra HTTP headers feature. Chromium matches every
|
||||||
|
// scoped header against every paused sub-resource request, so the total
|
||||||
|
// matching work is the product of the header count and the sub-resource count.
|
||||||
|
// These caps bound the factors the client controls; [scopeMatchBudget] bounds
|
||||||
|
// the product. See https://github.com/gotenberg/gotenberg/issues/1588.
|
||||||
|
const (
|
||||||
|
maxExtraHttpHeaders = 64
|
||||||
|
maxExtraHttpHeaderScopeLength = 1024
|
||||||
|
|
||||||
|
// A scope pattern matches against a URL, which takes microseconds for any
|
||||||
|
// reasonable pattern.
|
||||||
|
extraHttpHeaderScopeMatchTimeout = 250 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
var sameSiteRegexp = regexp2.MustCompile(
|
var sameSiteRegexp = regexp2.MustCompile(
|
||||||
`("sameSite"\s*:\s*")(?i:(lax|strict|none))(")`,
|
`("sameSite"\s*:\s*")(?i:(lax|strict|none))(")`,
|
||||||
regexp2.None,
|
regexp2.None,
|
||||||
@@ -169,6 +183,10 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
|||||||
return fmt.Errorf("unmarshal extraHttpHeaders: %w", err)
|
return fmt.Errorf("unmarshal extraHttpHeaders: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(headers) > maxExtraHttpHeaders {
|
||||||
|
return fmt.Errorf("too many headers, got %d, expected at most %d", len(headers), maxExtraHttpHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
var scope string
|
var scope string
|
||||||
var valueTokens []string
|
var valueTokens []string
|
||||||
@@ -198,12 +216,17 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
|||||||
|
|
||||||
var scopeRegexp *regexp2.Regexp
|
var scopeRegexp *regexp2.Regexp
|
||||||
if len(scope) > 0 {
|
if len(scope) > 0 {
|
||||||
|
if len(scope) > maxExtraHttpHeaderScopeLength {
|
||||||
|
err = errors.Join(err, fmt.Errorf("scope regex pattern for header '%s' is too long, got %d characters, expected at most %d", k, len(scope), maxExtraHttpHeaderScopeLength))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
p, errCompile := regexp2.Compile(scope, regexp2.None)
|
p, errCompile := regexp2.Compile(scope, regexp2.None)
|
||||||
if errCompile != nil {
|
if errCompile != nil {
|
||||||
err = errors.Join(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile))
|
err = errors.Join(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
p.MatchTimeout = 5 * time.Second
|
p.MatchTimeout = extraHttpHeaderScopeMatchTimeout
|
||||||
scopeRegexp = p
|
scopeRegexp = p
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,6 +366,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
|||||||
var (
|
var (
|
||||||
width, height int
|
width, height int
|
||||||
clip bool
|
clip bool
|
||||||
|
selector string
|
||||||
format string
|
format string
|
||||||
quality int
|
quality int
|
||||||
optimizeForSpeed bool
|
optimizeForSpeed bool
|
||||||
@@ -353,6 +377,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
|||||||
Int("width", &width, defaultScreenshotOptions.Width).
|
Int("width", &width, defaultScreenshotOptions.Width).
|
||||||
Int("height", &height, defaultScreenshotOptions.Height).
|
Int("height", &height, defaultScreenshotOptions.Height).
|
||||||
Bool("clip", &clip, defaultScreenshotOptions.Clip).
|
Bool("clip", &clip, defaultScreenshotOptions.Clip).
|
||||||
|
String("selector", &selector, defaultScreenshotOptions.Selector).
|
||||||
Custom("format", func(value string) error {
|
Custom("format", func(value string) error {
|
||||||
if value == "" {
|
if value == "" {
|
||||||
format = defaultScreenshotOptions.Format
|
format = defaultScreenshotOptions.Format
|
||||||
@@ -397,6 +422,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
|||||||
Width: width,
|
Width: width,
|
||||||
Height: height,
|
Height: height,
|
||||||
Clip: clip,
|
Clip: clip,
|
||||||
|
Selector: selector,
|
||||||
Format: format,
|
Format: format,
|
||||||
Quality: quality,
|
Quality: quality,
|
||||||
OptimizeForSpeed: optimizeForSpeed,
|
OptimizeForSpeed: optimizeForSpeed,
|
||||||
@@ -448,11 +474,18 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||||
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
||||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||||
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
watermarks, wErr := pdfengines.FormDataPdfWatermarks(form)
|
||||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
if wErr != nil {
|
||||||
stamp := pdfengines.FormDataPdfStamp(form, false)
|
return fmt.Errorf("form data watermarks: %w", wErr)
|
||||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
}
|
||||||
|
stamps, sErr := pdfengines.FormDataPdfStamps(form)
|
||||||
|
if sErr != nil {
|
||||||
|
return fmt.Errorf("form data stamps: %w", sErr)
|
||||||
|
}
|
||||||
|
var watermarkFiles, stampFiles []string
|
||||||
|
form.Watermarks(&watermarkFiles).Stamps(&stampFiles)
|
||||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
|
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||||
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||||
|
|
||||||
@@ -469,16 +502,16 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("reject URL scheme: %w", err)
|
return fmt.Errorf("reject URL scheme: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
|
err = pdfengines.BindWatermarkFiles(watermarks, watermarkFiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate watermark: %w", err)
|
return fmt.Errorf("bind watermark files: %w", err)
|
||||||
}
|
}
|
||||||
err = pdfengines.EnsureStampFile(&stamp, stampFile)
|
err = pdfengines.BindStampFiles(stamps, stampFiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate stamp: %w", err)
|
return fmt.Errorf("bind stamp files: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
|
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermarks, stamps, rotateAngle, rotatePages, optimizeImages, imageQuality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert URL to PDF: %w", err)
|
return fmt.Errorf("convert URL to PDF: %w", err)
|
||||||
}
|
}
|
||||||
@@ -537,11 +570,18 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||||
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
||||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||||
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
watermarks, wErr := pdfengines.FormDataPdfWatermarks(form)
|
||||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
if wErr != nil {
|
||||||
stamp := pdfengines.FormDataPdfStamp(form, false)
|
return fmt.Errorf("form data watermarks: %w", wErr)
|
||||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
}
|
||||||
|
stamps, sErr := pdfengines.FormDataPdfStamps(form)
|
||||||
|
if sErr != nil {
|
||||||
|
return fmt.Errorf("form data stamps: %w", sErr)
|
||||||
|
}
|
||||||
|
var watermarkFiles, stampFiles []string
|
||||||
|
form.Watermarks(&watermarkFiles).Stamps(&stampFiles)
|
||||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
|
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||||
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||||
|
|
||||||
@@ -553,18 +593,18 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("validate form data: %w", err)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
|
err = pdfengines.BindWatermarkFiles(watermarks, watermarkFiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate watermark: %w", err)
|
return fmt.Errorf("bind watermark files: %w", err)
|
||||||
}
|
}
|
||||||
err = pdfengines.EnsureStampFile(&stamp, stampFile)
|
err = pdfengines.BindStampFiles(stamps, stampFiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate stamp: %w", err)
|
return fmt.Errorf("bind stamp files: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("file://%s", inputPath)
|
url := fmt.Sprintf("file://%s", inputPath)
|
||||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
||||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
|
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermarks, stamps, rotateAngle, rotatePages, optimizeImages, imageQuality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert HTML to PDF: %w", err)
|
return fmt.Errorf("convert HTML to PDF: %w", err)
|
||||||
}
|
}
|
||||||
@@ -620,11 +660,18 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||||
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
||||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||||
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
watermarks, wErr := pdfengines.FormDataPdfWatermarks(form)
|
||||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
if wErr != nil {
|
||||||
stamp := pdfengines.FormDataPdfStamp(form, false)
|
return fmt.Errorf("form data watermarks: %w", wErr)
|
||||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
}
|
||||||
|
stamps, sErr := pdfengines.FormDataPdfStamps(form)
|
||||||
|
if sErr != nil {
|
||||||
|
return fmt.Errorf("form data stamps: %w", sErr)
|
||||||
|
}
|
||||||
|
var watermarkFiles, stampFiles []string
|
||||||
|
form.Watermarks(&watermarkFiles).Stamps(&stampFiles)
|
||||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
|
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||||
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||||
|
|
||||||
@@ -641,13 +688,13 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("validate form data: %w", err)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
|
err = pdfengines.BindWatermarkFiles(watermarks, watermarkFiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate watermark: %w", err)
|
return fmt.Errorf("bind watermark files: %w", err)
|
||||||
}
|
}
|
||||||
err = pdfengines.EnsureStampFile(&stamp, stampFile)
|
err = pdfengines.BindStampFiles(stamps, stampFiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate stamp: %w", err)
|
return fmt.Errorf("bind stamp files: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
url, err := markdownToHtml(ctx, inputPath, markdownPaths)
|
url, err := markdownToHtml(ctx, inputPath, markdownPaths)
|
||||||
@@ -656,7 +703,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
}
|
}
|
||||||
|
|
||||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
||||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
|
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermarks, stamps, rotateAngle, rotatePages, optimizeImages, imageQuality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert markdown to PDF: %w", err)
|
return fmt.Errorf("convert markdown to PDF: %w", err)
|
||||||
}
|
}
|
||||||
@@ -781,7 +828,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
|
|||||||
return fmt.Sprintf("file://%s", inputPath), nil
|
return fmt.Sprintf("file://%s", inputPath), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, 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 {
|
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, encrypt gotenberg.EncryptOptions, embedPaths []string, embedsMetadata map[string]map[string]string, facturX gotenberg.FacturX, facturxXmlPath string, watermarks, stamps []gotenberg.Stamp, rotateAngle int, rotatePages string, optimizeImages bool, imageQuality int) error {
|
||||||
outputPath := ctx.GeneratePath(".pdf")
|
outputPath := ctx.GeneratePath(".pdf")
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/1130.
|
// See https://github.com/gotenberg/gotenberg/issues/1130.
|
||||||
filename := ctx.OutputFilename(outputPath)
|
filename := ctx.OutputFilename(outputPath)
|
||||||
@@ -863,12 +910,12 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
|||||||
return fmt.Errorf("split PDF: %w", err)
|
return fmt.Errorf("split PDF: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.WatermarkStub(ctx, engine, watermark, outputPaths)
|
err = pdfengines.WatermarkStub(ctx, engine, watermarks, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("watermark PDFs: %w", err)
|
return fmt.Errorf("watermark PDFs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.StampStub(ctx, engine, stamp, outputPaths)
|
err = pdfengines.StampStub(ctx, engine, stamps, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("stamp PDFs: %w", err)
|
return fmt.Errorf("stamp PDFs: %w", err)
|
||||||
}
|
}
|
||||||
@@ -878,6 +925,11 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
|||||||
return fmt.Errorf("rotate PDFs: %w", err)
|
return fmt.Errorf("rotate PDFs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = pdfengines.OptimizeStub(ctx, engine, optimizeImages, imageQuality, outputPaths)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("optimize PDF images: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
pdfFormats = pdfengines.FacturXPdfFormats(ctx, engine, facturX, pdfFormats, true, nil)
|
pdfFormats = pdfengines.FacturXPdfFormats(ctx, engine, facturX, pdfFormats, true, nil)
|
||||||
|
|
||||||
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
||||||
@@ -940,7 +992,17 @@ func screenshotUrl(ctx *api.Context, chromium Api, url string, options Screensho
|
|||||||
outputPath := ctx.GeneratePath(ext)
|
outputPath := ctx.GeneratePath(ext)
|
||||||
|
|
||||||
err := chromium.Screenshot(ctx, ctx.Log(), url, outputPath, options)
|
err := chromium.Screenshot(ctx, ctx.Log(), url, outputPath, options)
|
||||||
err = handleChromiumError(err, options.Options)
|
if errors.Is(err, ErrScreenshotSelectorNotFound) {
|
||||||
|
err = api.WrapError(
|
||||||
|
err,
|
||||||
|
api.NewSentinelHttpError(
|
||||||
|
http.StatusBadRequest,
|
||||||
|
fmt.Sprintf("The selector '%s' (selector) matched no element with a visible box", options.Selector),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
err = handleChromiumError(err, options.Options)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("screenshot: %w", err)
|
return fmt.Errorf("screenshot: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
52
pkg/modules/chromium/scopebudget.go
Normal file
52
pkg/modules/chromium/scopebudget.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package chromium
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// scopeMatchBudgetPerConversion caps the total time a single conversion may
|
||||||
|
// spend matching scoped extra HTTP header patterns.
|
||||||
|
//
|
||||||
|
// The per-pattern MatchTimeout bounds one match, not their number: Chromium
|
||||||
|
// pauses every sub-resource request, and each paused request is matched against
|
||||||
|
// every scoped header. Without a shared budget the total is the product of the
|
||||||
|
// two, both of which the client controls.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1588.
|
||||||
|
const scopeMatchBudgetPerConversion = 5 * time.Second
|
||||||
|
|
||||||
|
// scopeMatchBudget is a time allowance shared by every scope match of a
|
||||||
|
// conversion. It is safe for concurrent use: paused requests are handled on
|
||||||
|
// their own goroutines.
|
||||||
|
type scopeMatchBudget struct {
|
||||||
|
remaining atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// newScopeMatchBudget returns a [scopeMatchBudget] allowing d of matching.
|
||||||
|
func newScopeMatchBudget(d time.Duration) *scopeMatchBudget {
|
||||||
|
b := new(scopeMatchBudget)
|
||||||
|
b.remaining.Store(int64(d))
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryAcquire reports whether the budget still allows a match.
|
||||||
|
func (b *scopeMatchBudget) tryAcquire() bool {
|
||||||
|
return b.remaining.Load() > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// consume subtracts the time a match took. It saturates at zero so that a long
|
||||||
|
// match cannot wrap the counter back into credit.
|
||||||
|
func (b *scopeMatchBudget) consume(d time.Duration) {
|
||||||
|
for {
|
||||||
|
current := b.remaining.Load()
|
||||||
|
if current <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next := max(current-int64(d), 0)
|
||||||
|
|
||||||
|
if b.remaining.CompareAndSwap(current, next) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
122
pkg/modules/chromium/scopebudget_test.go
Normal file
122
pkg/modules/chromium/scopebudget_test.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package chromium
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dlclark/regexp2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScopeMatchBudget(t *testing.T) {
|
||||||
|
t.Run("allows matching while credit remains", func(t *testing.T) {
|
||||||
|
b := newScopeMatchBudget(time.Second)
|
||||||
|
if !b.tryAcquire() {
|
||||||
|
t.Fatal("tryAcquire() = false on a fresh budget, want true")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("denies matching once exhausted", func(t *testing.T) {
|
||||||
|
b := newScopeMatchBudget(time.Second)
|
||||||
|
b.consume(time.Second)
|
||||||
|
if b.tryAcquire() {
|
||||||
|
t.Error("tryAcquire() = true after the budget was spent, want false")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("saturates at zero instead of wrapping into credit", func(t *testing.T) {
|
||||||
|
b := newScopeMatchBudget(time.Second)
|
||||||
|
b.consume(time.Hour)
|
||||||
|
if got := b.remaining.Load(); got != 0 {
|
||||||
|
t.Errorf("remaining = %d, want 0", got)
|
||||||
|
}
|
||||||
|
if b.tryAcquire() {
|
||||||
|
t.Error("tryAcquire() = true after an overlong match, want false")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a spent budget stays spent", func(t *testing.T) {
|
||||||
|
b := newScopeMatchBudget(time.Second)
|
||||||
|
b.consume(time.Second)
|
||||||
|
b.consume(time.Millisecond)
|
||||||
|
if got := b.remaining.Load(); got != 0 {
|
||||||
|
t.Errorf("remaining = %d, want 0", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("is safe for concurrent use", func(t *testing.T) {
|
||||||
|
const goroutines = 64
|
||||||
|
// Each goroutine spends 1ms against a budget of half that many
|
||||||
|
// milliseconds, so the total spend overshoots it.
|
||||||
|
b := newScopeMatchBudget(time.Duration(goroutines/2) * time.Millisecond)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range goroutines {
|
||||||
|
wg.Go(func() {
|
||||||
|
b.tryAcquire()
|
||||||
|
b.consume(time.Millisecond)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if got := b.remaining.Load(); got != 0 {
|
||||||
|
t.Errorf("remaining = %d, want 0", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScopeMatchBudget_BoundsCatastrophicBacktracking is the regression test for
|
||||||
|
// the amplification: many scoped headers matched against a hostile URL must cost
|
||||||
|
// the budget, not a multiple of it.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1588.
|
||||||
|
func TestScopeMatchBudget_BoundsCatastrophicBacktracking(t *testing.T) {
|
||||||
|
const (
|
||||||
|
headers = 16
|
||||||
|
budget = 200 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// Nested quantifier with no possible match: classic catastrophic
|
||||||
|
// backtracking.
|
||||||
|
pattern := compileScopePattern(t, `(a+)+b`)
|
||||||
|
url := "http://example.com/" + strings.Repeat("a", 40)
|
||||||
|
|
||||||
|
b := newScopeMatchBudget(budget)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
var matched int
|
||||||
|
for range headers {
|
||||||
|
if !b.tryAcquire() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
matchStart := time.Now()
|
||||||
|
_, _ = pattern.MatchString(url)
|
||||||
|
b.consume(time.Since(matchStart))
|
||||||
|
matched++
|
||||||
|
}
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if matched == headers {
|
||||||
|
t.Errorf("all %d headers were matched, want the budget to stop matching early", headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each match is separately capped at extraHttpHeaderScopeMatchTimeout, so
|
||||||
|
// the worst case is the budget plus one final match that started with the
|
||||||
|
// last of the credit. Generous slack keeps this stable on a loaded CI box.
|
||||||
|
ceiling := budget + extraHttpHeaderScopeMatchTimeout + time.Second
|
||||||
|
if elapsed > ceiling {
|
||||||
|
t.Errorf("matching took %s, want at most %s", elapsed, ceiling)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func compileScopePattern(t *testing.T, pattern string) *regexp2.Regexp {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
p, err := regexp2.Compile(pattern, regexp2.None)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("compile %q: %v", pattern, err)
|
||||||
|
}
|
||||||
|
p.MatchTimeout = extraHttpHeaderScopeMatchTimeout
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
@@ -6,13 +6,17 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/chromedp/cdproto/cdp"
|
"github.com/chromedp/cdproto/cdp"
|
||||||
"github.com/chromedp/cdproto/emulation"
|
"github.com/chromedp/cdproto/emulation"
|
||||||
"github.com/chromedp/cdproto/network"
|
"github.com/chromedp/cdproto/network"
|
||||||
"github.com/chromedp/cdproto/page"
|
"github.com/chromedp/cdproto/page"
|
||||||
|
"github.com/chromedp/cdproto/runtime"
|
||||||
|
"github.com/chromedp/cdproto/storage"
|
||||||
"github.com/chromedp/chromedp"
|
"github.com/chromedp/chromedp"
|
||||||
"go.opentelemetry.io/otel/attribute"
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.opentelemetry.io/otel/codes"
|
"go.opentelemetry.io/otel/codes"
|
||||||
@@ -21,8 +25,29 @@ import (
|
|||||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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 {
|
func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
|
if options.GenerateDocumentOutline && !options.GenerateTaggedPdf {
|
||||||
|
logger.DebugContext(ctx, "document outline requested, enabling tagged PDF because Chromium derives the outline from the structure tree")
|
||||||
|
}
|
||||||
|
|
||||||
|
options = resolvePdfOptions(options)
|
||||||
|
|
||||||
// ctx is the chromedp task context, derived from context.Background(),
|
// ctx is the chromedp task context, derived from context.Background(),
|
||||||
// so the span is started under reqCtx to keep print_to_pdf in the
|
// so the span is started under reqCtx to keep print_to_pdf in the
|
||||||
// conversion trace instead of orphaning it into a new one.
|
// conversion trace instead of orphaning it into a new one.
|
||||||
@@ -33,6 +58,7 @@ func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPat
|
|||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
err := func() error {
|
err := func() error {
|
||||||
|
paperWidth := options.PaperWidth
|
||||||
paperHeight := options.PaperHeight
|
paperHeight := options.PaperHeight
|
||||||
pageRanges := options.PageRanges
|
pageRanges := options.PageRanges
|
||||||
|
|
||||||
@@ -46,9 +72,20 @@ func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPat
|
|||||||
|
|
||||||
// There are 96 CSS pixels per inch.
|
// There are 96 CSS pixels per inch.
|
||||||
// See https://issues.chromium.org/issues/40267771#comment14.
|
// See https://issues.chromium.org/issues/40267771#comment14.
|
||||||
// We add top and bottom margins so that the content area
|
if options.Landscape {
|
||||||
// is large enough to fit the entire content.
|
// Landscape swaps the paper dimensions, so the page is
|
||||||
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
|
// WithPaperHeight wide by WithPaperWidth tall. Size both to
|
||||||
|
// the content so the width expands to fit a wide document
|
||||||
|
// (e.g. a table) instead of the height-only expansion
|
||||||
|
// landing on the width axis and truncating it.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1390.
|
||||||
|
paperWidth = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
|
||||||
|
paperHeight = (cssContentSize.Width / 96) + options.MarginLeft + options.MarginRight
|
||||||
|
} else {
|
||||||
|
// We add top and bottom margins so that the content area
|
||||||
|
// is large enough to fit the entire content.
|
||||||
|
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
|
||||||
|
}
|
||||||
pageRanges = "1" // little dirty hack to avoid leftovers.
|
pageRanges = "1" // little dirty hack to avoid leftovers.
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +94,7 @@ func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPat
|
|||||||
WithLandscape(options.Landscape).
|
WithLandscape(options.Landscape).
|
||||||
WithPrintBackground(options.PrintBackground).
|
WithPrintBackground(options.PrintBackground).
|
||||||
WithScale(options.Scale).
|
WithScale(options.Scale).
|
||||||
WithPaperWidth(options.PaperWidth).
|
WithPaperWidth(paperWidth).
|
||||||
WithPaperHeight(paperHeight).
|
WithPaperHeight(paperHeight).
|
||||||
WithMarginTop(options.MarginTop).
|
WithMarginTop(options.MarginTop).
|
||||||
WithMarginBottom(options.MarginBottom).
|
WithMarginBottom(options.MarginBottom).
|
||||||
@@ -167,7 +204,16 @@ func captureScreenshotActionFunc(logger *slog.Logger, outputPath string, options
|
|||||||
WithOptimizeForSpeed(options.OptimizeForSpeed).
|
WithOptimizeForSpeed(options.OptimizeForSpeed).
|
||||||
WithFormat(page.CaptureScreenshotFormat(options.Format))
|
WithFormat(page.CaptureScreenshotFormat(options.Format))
|
||||||
|
|
||||||
if options.Clip {
|
switch {
|
||||||
|
case options.Selector != "":
|
||||||
|
clip, err := elementClip(ctx, options.Selector)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugContext(ctx, fmt.Sprintf("clip screenshot to selector '%s'", options.Selector))
|
||||||
|
captureScreenshot = captureScreenshot.WithClip(clip)
|
||||||
|
case options.Clip:
|
||||||
captureScreenshot = captureScreenshot.WithClip(&page.Viewport{
|
captureScreenshot = captureScreenshot.WithClip(&page.Viewport{
|
||||||
Width: float64(options.Width),
|
Width: float64(options.Width),
|
||||||
Height: float64(options.Height),
|
Height: float64(options.Height),
|
||||||
@@ -208,6 +254,49 @@ func captureScreenshotActionFunc(logger *slog.Logger, outputPath string, options
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// elementClip resolves the first element matching selector to a page-space clip
|
||||||
|
// rectangle for Page.captureScreenshot.
|
||||||
|
//
|
||||||
|
// getBoundingClientRect reports viewport-relative CSS pixels; adding the scroll
|
||||||
|
// offset puts the rectangle in the document coordinate space that
|
||||||
|
// WithCaptureBeyondViewport expects. It fails with
|
||||||
|
// [ErrScreenshotSelectorNotFound] when nothing matches or the match has no
|
||||||
|
// rendered box (display:none or a zero area), so the caller can answer 400.
|
||||||
|
func elementClip(ctx context.Context, selector string) (*page.Viewport, error) {
|
||||||
|
var rect struct {
|
||||||
|
Found bool `json:"found"`
|
||||||
|
X float64 `json:"x"`
|
||||||
|
Y float64 `json:"y"`
|
||||||
|
Width float64 `json:"width"`
|
||||||
|
Height float64 `json:"height"`
|
||||||
|
}
|
||||||
|
|
||||||
|
expr := fmt.Sprintf(`(() => {
|
||||||
|
const el = document.querySelector(%s);
|
||||||
|
if (!el) {
|
||||||
|
return { found: false };
|
||||||
|
}
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return { found: true, x: r.left + window.scrollX, y: r.top + window.scrollY, width: r.width, height: r.height };
|
||||||
|
})()`, strconv.Quote(selector))
|
||||||
|
|
||||||
|
err := chromedp.Evaluate(expr, &rect).Do(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("evaluate selector box: %v: %w", err, ErrScreenshotSelectorNotFound)
|
||||||
|
}
|
||||||
|
if !rect.Found || rect.Width <= 0 || rect.Height <= 0 {
|
||||||
|
return nil, fmt.Errorf("selector %q matched no element with a visible box: %w", selector, ErrScreenshotSelectorNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &page.Viewport{
|
||||||
|
X: rect.X,
|
||||||
|
Y: rect.Y,
|
||||||
|
Width: rect.Width,
|
||||||
|
Height: rect.Height,
|
||||||
|
Scale: 1,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func setDeviceMetricsOverride(logger *slog.Logger, width, height int, deviceScaleFactor float64) chromedp.ActionFunc {
|
func setDeviceMetricsOverride(logger *slog.Logger, width, height int, deviceScaleFactor float64) chromedp.ActionFunc {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger.DebugContext(ctx, "set device metrics override")
|
logger.DebugContext(ctx, "set device metrics override")
|
||||||
@@ -259,6 +348,54 @@ func clearCookiesActionFunc(logger *slog.Logger, clear bool) chromedp.ActionFunc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clearStorageActionFunc clears the converted origin's local storage before the
|
||||||
|
// page loads, so state written by a previous conversion of the same origin does
|
||||||
|
// not leak into this one. See https://github.com/gotenberg/gotenberg/issues/919.
|
||||||
|
//
|
||||||
|
// Session storage is not touched: each conversion runs in its own browsing
|
||||||
|
// context (a fresh tab), so it is already isolated and cannot leak. Local
|
||||||
|
// storage is per-origin and shared across tabs of the long-lived browser, so it
|
||||||
|
// is the only web storage that carries over.
|
||||||
|
func clearStorageActionFunc(logger *slog.Logger, clear bool, rawURL string) chromedp.ActionFunc {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if !clear {
|
||||||
|
logger.DebugContext(ctx, "local storage not cleared")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
origin, ok := httpOrigin(rawURL)
|
||||||
|
if !ok {
|
||||||
|
// A file:// upload gets an opaque, per-request origin that is not
|
||||||
|
// shared between conversions, so there is nothing to clear.
|
||||||
|
logger.DebugContext(ctx, "local storage not cleared: non-http(s) origin is already isolated")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugContext(ctx, fmt.Sprintf("clear local storage for %s", origin))
|
||||||
|
|
||||||
|
err := storage.ClearDataForOrigin(origin, string(storage.TypeLocalStorage)).Do(ctx)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("clear local storage: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpOrigin returns the http(s) security origin (scheme://host[:port]) of
|
||||||
|
// rawURL, and false when rawURL is not http(s). A non-http(s) URL such as a
|
||||||
|
// file:// upload has an opaque origin that no other conversion shares.
|
||||||
|
func httpOrigin(rawURL string) (string, bool) {
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host), true
|
||||||
|
}
|
||||||
|
|
||||||
func disableJavaScriptActionFunc(logger *slog.Logger, disable bool) chromedp.ActionFunc {
|
func disableJavaScriptActionFunc(logger *slog.Logger, disable bool) chromedp.ActionFunc {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/175.
|
// See https://github.com/gotenberg/gotenberg/issues/175.
|
||||||
@@ -572,7 +709,13 @@ func waitForExpressionBeforePrintActionFunc(logger *slog.Logger, disableJavaScri
|
|||||||
return fmt.Errorf("context done while evaluating '%s': %w", expression, ctx.Err())
|
return fmt.Errorf("context done while evaluating '%s': %w", expression, ctx.Err())
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
var ok bool
|
var ok bool
|
||||||
evaluate := chromedp.Evaluate(expression, &ok)
|
// Await the result so a thenable expression (an async function
|
||||||
|
// returning a Promise) resolves before its value is read. A
|
||||||
|
// non-promise result is unaffected.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/pull/1617.
|
||||||
|
evaluate := chromedp.Evaluate(expression, &ok, func(p *runtime.EvaluateParams) *runtime.EvaluateParams {
|
||||||
|
return p.WithAwaitPromise(true)
|
||||||
|
})
|
||||||
|
|
||||||
err := evaluate.Do(ctx)
|
err := evaluate.Do(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
52
pkg/modules/chromium/tasks_test.go
Normal file
52
pkg/modules/chromium/tasks_test.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package chromium
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestResolvePdfOptions(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
generateOutline bool
|
||||||
|
generateTaggedIn bool
|
||||||
|
generateTaggedWant bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "outline requested forces tagged PDF",
|
||||||
|
generateOutline: true,
|
||||||
|
generateTaggedIn: false,
|
||||||
|
generateTaggedWant: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "outline requested keeps tagged PDF on",
|
||||||
|
generateOutline: true,
|
||||||
|
generateTaggedIn: true,
|
||||||
|
generateTaggedWant: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "no outline leaves tagged PDF off",
|
||||||
|
generateOutline: false,
|
||||||
|
generateTaggedIn: false,
|
||||||
|
generateTaggedWant: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "no outline keeps tagged PDF on",
|
||||||
|
generateOutline: false,
|
||||||
|
generateTaggedIn: true,
|
||||||
|
generateTaggedWant: true,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
options := DefaultPdfOptions()
|
||||||
|
options.GenerateDocumentOutline = tc.generateOutline
|
||||||
|
options.GenerateTaggedPdf = tc.generateTaggedIn
|
||||||
|
|
||||||
|
got := resolvePdfOptions(options)
|
||||||
|
|
||||||
|
if got.GenerateTaggedPdf != tc.generateTaggedWant {
|
||||||
|
t.Errorf("expected GenerateTaggedPdf=%t, got %t", tc.generateTaggedWant, got.GenerateTaggedPdf)
|
||||||
|
}
|
||||||
|
if got.GenerateDocumentOutline != tc.generateOutline {
|
||||||
|
t.Errorf("expected GenerateDocumentOutline=%t, got %t", tc.generateOutline, got.GenerateDocumentOutline)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,18 +115,29 @@ func buildExifToolWriteArgs(metadata map[string]any) ([]string, error) {
|
|||||||
return nil, fmt.Errorf("write PDF metadata with ExifTool: invalid metadata key %q: %w", key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
return nil, fmt.Errorf("write PDF metadata with ExifTool: invalid metadata key %q: %w", key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tag := key
|
||||||
|
if key == "Trapped" {
|
||||||
|
// ExifTool writes the document info /Trapped entry as a malformed
|
||||||
|
// name-in-a-string, e.g. "(/Unknown)". pdfcpu's stricter validation
|
||||||
|
// (as of v0.15) rejects it, which breaks later pdfcpu operations on
|
||||||
|
// the file such as embedding. Writing Trapped to XMP keeps the value
|
||||||
|
// readable without the invalid document info entry.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1628.
|
||||||
|
tag = "XMP-pdf:Trapped"
|
||||||
|
}
|
||||||
|
|
||||||
switch val := value.(type) {
|
switch val := value.(type) {
|
||||||
case string:
|
case string:
|
||||||
if err := validateMetadataValue(key, val); err != nil {
|
if err := validateMetadataValue(key, val); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
args = append(args, fmt.Sprintf("-%s=%s", key, val))
|
args = append(args, fmt.Sprintf("-%s=%s", tag, val))
|
||||||
case []string:
|
case []string:
|
||||||
for _, s := range val {
|
for _, s := range val {
|
||||||
if err := validateMetadataValue(key, s); err != nil {
|
if err := validateMetadataValue(key, s); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
args = append(args, fmt.Sprintf("-%s=%s", key, s))
|
args = append(args, fmt.Sprintf("-%s=%s", tag, s))
|
||||||
}
|
}
|
||||||
case []any:
|
case []any:
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/1048.
|
// See https://github.com/gotenberg/gotenberg/issues/1048.
|
||||||
@@ -138,18 +149,18 @@ func buildExifToolWriteArgs(metadata map[string]any) ([]string, error) {
|
|||||||
if err := validateMetadataValue(key, s); err != nil {
|
if err := validateMetadataValue(key, s); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
args = append(args, fmt.Sprintf("-%s=%s", key, s))
|
args = append(args, fmt.Sprintf("-%s=%s", tag, s))
|
||||||
}
|
}
|
||||||
case bool:
|
case bool:
|
||||||
args = append(args, fmt.Sprintf("-%s=%t", key, val))
|
args = append(args, fmt.Sprintf("-%s=%t", tag, val))
|
||||||
case int:
|
case int:
|
||||||
args = append(args, fmt.Sprintf("-%s=%d", key, val))
|
args = append(args, fmt.Sprintf("-%s=%d", tag, val))
|
||||||
case int64:
|
case int64:
|
||||||
args = append(args, fmt.Sprintf("-%s=%d", key, val))
|
args = append(args, fmt.Sprintf("-%s=%d", tag, val))
|
||||||
case float32:
|
case float32:
|
||||||
args = append(args, fmt.Sprintf("-%s=%g", key, val))
|
args = append(args, fmt.Sprintf("-%s=%g", tag, val))
|
||||||
case float64:
|
case float64:
|
||||||
args = append(args, fmt.Sprintf("-%s=%g", key, val))
|
args = append(args, fmt.Sprintf("-%s=%g", tag, val))
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("write PDF metadata with ExifTool: unsupported type %T for key %q: %w", value, key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
return nil, fmt.Errorf("write PDF metadata with ExifTool: unsupported type %T for key %q: %w", value, key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
||||||
}
|
}
|
||||||
@@ -296,6 +307,20 @@ func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, format
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OptimizeImages is not available in this implementation.
|
||||||
|
func (engine *ExifTool) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
|
||||||
|
_, span := gotenberg.Tracer().Start(ctx, "exiftool.OptimizeImages",
|
||||||
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
|
)
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
err := fmt.Errorf("optimize PDF images with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// ReadMetadata extracts the metadata of a given PDF file by invoking
|
// ReadMetadata extracts the metadata of a given PDF file by invoking
|
||||||
// the exiftool binary with "-j" (JSON output) and parsing the result.
|
// the exiftool binary with "-j" (JSON output) and parsing the result.
|
||||||
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||||
|
|||||||
@@ -33,12 +33,33 @@ var (
|
|||||||
// formats option.
|
// formats option.
|
||||||
ErrInvalidPdfFormats = errors.New("invalid PDF formats")
|
ErrInvalidPdfFormats = errors.New("invalid PDF formats")
|
||||||
|
|
||||||
// ErrUnoException happens when unoconverter returns exit code 5.
|
// ErrUnoException happens when unoconverter returns exit code 5. That code
|
||||||
|
// is the residual bucket of unoconverter's catch-all UNO exception handler:
|
||||||
|
// it covers a malformed page range, a password supplied to a document that
|
||||||
|
// does not need one, a failure to open the document and a failure to write
|
||||||
|
// the output alike. It names the exception class that was caught, not a
|
||||||
|
// cause. See https://github.com/gotenberg/gotenberg/issues/1588.
|
||||||
ErrUnoException = errors.New("uno exception")
|
ErrUnoException = errors.New("uno exception")
|
||||||
|
|
||||||
// ErrRuntimeException happens when unoconverter returns exit code 6.
|
// ErrRuntimeException happens when unoconverter returns exit code 6.
|
||||||
|
// unoconverter's own message for it reads "Office probably died", yet a
|
||||||
|
// wrong or missing password also surfaces there. Like [ErrUnoException], it
|
||||||
|
// does not establish who is at fault.
|
||||||
ErrRuntimeException = errors.New("runtime exception")
|
ErrRuntimeException = errors.New("runtime exception")
|
||||||
|
|
||||||
|
// ErrIoException happens when unoconverter returns exit code 3. LibreOffice
|
||||||
|
// could not read the source document.
|
||||||
|
ErrIoException = errors.New("io exception")
|
||||||
|
|
||||||
|
// ErrCannotConvertException happens when unoconverter returns exit code 4.
|
||||||
|
// LibreOffice read the document but could not convert it to PDF.
|
||||||
|
ErrCannotConvertException = errors.New("cannot convert exception")
|
||||||
|
|
||||||
|
// ErrIllegalArgumentException happens when unoconverter returns exit code
|
||||||
|
// 8. LibreOffice rejected the source document, usually because its contents
|
||||||
|
// do not match its extension.
|
||||||
|
ErrIllegalArgumentException = errors.New("illegal argument exception")
|
||||||
|
|
||||||
// ErrCoreDumped happens randomly; sometimes a conversion will work as
|
// ErrCoreDumped happens randomly; sometimes a conversion will work as
|
||||||
// expected, and some other time the same conversion will fail.
|
// expected, and some other time the same conversion will fail.
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/639.
|
// See https://github.com/gotenberg/gotenberg/issues/639.
|
||||||
@@ -336,6 +357,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
fs.StringSlice("libreoffice-deny-list", []string{}, "Set the denied URLs for LibreOffice outbound fetches using regular expressions - supports multiple values")
|
fs.StringSlice("libreoffice-deny-list", []string{}, "Set the denied URLs for LibreOffice outbound fetches using regular expressions - supports multiple values")
|
||||||
fs.Bool("libreoffice-deny-private-ips", false, "Reject LibreOffice outbound URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted documents to mitigate SSRF against internal services")
|
fs.Bool("libreoffice-deny-private-ips", false, "Reject LibreOffice outbound URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted documents to mitigate SSRF against internal services")
|
||||||
fs.Bool("libreoffice-deny-public-ips", false, "Reject LibreOffice outbound URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent outbound traffic from leaving a private network")
|
fs.Bool("libreoffice-deny-public-ips", false, "Reject LibreOffice outbound URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent outbound traffic from leaving a private network")
|
||||||
|
fs.Bool("libreoffice-enable-environment-proxy", false, "Route LibreOffice outbound fetches through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials")
|
||||||
|
|
||||||
return fs
|
return fs
|
||||||
}(),
|
}(),
|
||||||
@@ -363,10 +385,11 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
|||||||
unoBinPath: unoBinPath,
|
unoBinPath: unoBinPath,
|
||||||
startTimeout: flags.MustDuration("libreoffice-start-timeout"),
|
startTimeout: flags.MustDuration("libreoffice-start-timeout"),
|
||||||
proxyOptions: outboundProxyOptions{
|
proxyOptions: outboundProxyOptions{
|
||||||
allowList: flags.MustRegexpSlice("libreoffice-allow-list"),
|
allowList: flags.MustRegexpSlice("libreoffice-allow-list"),
|
||||||
denyList: flags.MustRegexpSlice("libreoffice-deny-list"),
|
denyList: flags.MustRegexpSlice("libreoffice-deny-list"),
|
||||||
denyPrivateIPs: flags.MustBool("libreoffice-deny-private-ips"),
|
denyPrivateIPs: flags.MustBool("libreoffice-deny-private-ips"),
|
||||||
denyPublicIPs: flags.MustBool("libreoffice-deny-public-ips"),
|
denyPublicIPs: flags.MustBool("libreoffice-deny-public-ips"),
|
||||||
|
enableEnvironmentProxy: flags.MustBool("libreoffice-enable-environment-proxy"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,6 +522,13 @@ func (a *Api) Validate() error {
|
|||||||
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))
|
err = errors.Join(err, fmt.Errorf("unoconverter binary does not exist at %q; check the UNOCONVERTER_BIN_PATH environment variable: %w", a.args.unoBinPath, statErr))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if a.args.proxyOptions.enableEnvironmentProxy {
|
||||||
|
proxyErr := gotenberg.ValidateEnvironmentProxyVariables()
|
||||||
|
if proxyErr != nil {
|
||||||
|
err = errors.Join(err, fmt.Errorf("--libreoffice-enable-environment-proxy is set: %w", proxyErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -765,7 +795,10 @@ func conversionRequestAttributes(inputPath string, options Options) []attribute.
|
|||||||
// [gotenberg.ClassifyError].
|
// [gotenberg.ClassifyError].
|
||||||
func libreofficeErrorType(err error) string {
|
func libreofficeErrorType(err error) string {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrInvalidPdfFormats):
|
case errors.Is(err, ErrInvalidPdfFormats),
|
||||||
|
errors.Is(err, ErrIoException),
|
||||||
|
errors.Is(err, ErrCannotConvertException),
|
||||||
|
errors.Is(err, ErrIllegalArgumentException):
|
||||||
return gotenberg.ErrorTypeInvalidInput
|
return gotenberg.ErrorTypeInvalidInput
|
||||||
case errors.Is(err, ErrUnoException), errors.Is(err, ErrRuntimeException):
|
case errors.Is(err, ErrUnoException), errors.Is(err, ErrRuntimeException):
|
||||||
return "libreoffice_exception"
|
return "libreoffice_exception"
|
||||||
@@ -846,6 +879,8 @@ func (a *Api) Extensions() []string {
|
|||||||
".potx",
|
".potx",
|
||||||
".ppm",
|
".ppm",
|
||||||
".pps",
|
".pps",
|
||||||
|
".ppsm",
|
||||||
|
".ppsx",
|
||||||
".ppt",
|
".ppt",
|
||||||
".pptm",
|
".pptm",
|
||||||
".pptx",
|
".pptx",
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ func TestLibreofficeErrorType(t *testing.T) {
|
|||||||
{"deadline", context.DeadlineExceeded, "timeout"},
|
{"deadline", context.DeadlineExceeded, "timeout"},
|
||||||
{"canceled", context.Canceled, "context_cancelled"},
|
{"canceled", context.Canceled, "context_cancelled"},
|
||||||
{"invalid pdf formats", ErrInvalidPdfFormats, "invalid_input"},
|
{"invalid pdf formats", ErrInvalidPdfFormats, "invalid_input"},
|
||||||
|
{"io exception", ErrIoException, "invalid_input"},
|
||||||
|
{"cannot convert exception", ErrCannotConvertException, "invalid_input"},
|
||||||
|
{"illegal argument exception", ErrIllegalArgumentException, "invalid_input"},
|
||||||
{"uno exception", ErrUnoException, "libreoffice_exception"},
|
{"uno exception", ErrUnoException, "libreoffice_exception"},
|
||||||
{"runtime exception", ErrRuntimeException, "libreoffice_exception"},
|
{"runtime exception", ErrRuntimeException, "libreoffice_exception"},
|
||||||
{"queue size exceeded", gotenberg.ErrMaximumQueueSizeExceeded, "libreoffice_unavailable"},
|
{"queue size exceeded", gotenberg.ErrMaximumQueueSizeExceeded, "libreoffice_unavailable"},
|
||||||
|
|||||||
@@ -293,6 +293,14 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, input
|
|||||||
return errors.New("LibreOffice not started, cannot handle PDF conversion")
|
return errors.New("LibreOffice not started, cannot handle PDF conversion")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SinglePageSheets starts each sheet's single page at the workbook's saved
|
||||||
|
// scroll position, truncating everything above and to the left of it.
|
||||||
|
// Render a copy with that position reset to the top-left cell instead.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1222.
|
||||||
|
if options.SinglePageSheets {
|
||||||
|
inputPath = resetCalcScrollPosition(ctx, logger, inputPath)
|
||||||
|
}
|
||||||
|
|
||||||
args := []string{
|
args := []string{
|
||||||
"--no-launch",
|
"--no-launch",
|
||||||
"--format",
|
"--format",
|
||||||
@@ -435,9 +443,11 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, input
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LibreOffice's errors are not explicit.
|
// LibreOffice's errors are not explicit: unoconverter derives its exit code
|
||||||
// For instance, exit code 5 may be explained by a malformed page range
|
// from the UNO exception class it caught, not from a diagnosis. Exit codes
|
||||||
// but also by a not required password.
|
// 5 and 6 are ambiguous in particular, so the route decides the HTTP status
|
||||||
|
// from the request and the document rather than from the code alone.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1588.
|
||||||
|
|
||||||
// We may want to retry in case of a core-dumped event.
|
// We may want to retry in case of a core-dumped event.
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/639.
|
// See https://github.com/gotenberg/gotenberg/issues/639.
|
||||||
@@ -445,13 +455,17 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, input
|
|||||||
return ErrCoreDumped
|
return ErrCoreDumped
|
||||||
}
|
}
|
||||||
|
|
||||||
if exitCode == 5 {
|
switch exitCode {
|
||||||
// Potentially malformed page ranges or password not required.
|
case 3:
|
||||||
|
return ErrIoException
|
||||||
|
case 4:
|
||||||
|
return ErrCannotConvertException
|
||||||
|
case 5:
|
||||||
return ErrUnoException
|
return ErrUnoException
|
||||||
}
|
case 6:
|
||||||
if exitCode == 6 {
|
|
||||||
// Password potentially required or invalid.
|
|
||||||
return ErrRuntimeException
|
return ErrRuntimeException
|
||||||
|
case 8:
|
||||||
|
return ErrIllegalArgumentException
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("convert to PDF: %w", err)
|
return fmt.Errorf("convert to PDF: %w", err)
|
||||||
|
|||||||
129
pkg/modules/libreoffice/api/protection.go
Normal file
129
pkg/modules/libreoffice/api/protection.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PasswordProtection describes whether a document requires a password to open.
|
||||||
|
type PasswordProtection int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// PasswordProtectionUnknown means the document's encryption state could not
|
||||||
|
// be determined.
|
||||||
|
PasswordProtectionUnknown PasswordProtection = iota
|
||||||
|
|
||||||
|
// PasswordProtectionNone means the document opens without a password.
|
||||||
|
PasswordProtectionNone
|
||||||
|
|
||||||
|
// PasswordProtectionRequired means the document is encrypted.
|
||||||
|
PasswordProtectionRequired
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Compound File Binary magic. An encrypted OOXML document is an
|
||||||
|
// MS-OFFCRYPTO container, which is a compound file. Per MS-CFB 2.2, the
|
||||||
|
// header signature is fixed.
|
||||||
|
ole2Magic = []byte{0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1}
|
||||||
|
|
||||||
|
// Local file header signature. Per APPNOTE.TXT 4.3.7, every ZIP entry
|
||||||
|
// starts with it, so an intact package starts with it too.
|
||||||
|
zipMagic = []byte{0x50, 0x4b, 0x03, 0x04}
|
||||||
|
|
||||||
|
// An unencrypted OOXML document is always a ZIP package, so any of these
|
||||||
|
// extensions over a compound file means the payload is encrypted. Legacy
|
||||||
|
// binary formats (.doc, .xls, .ppt) are compound files either way and are
|
||||||
|
// deliberately absent.
|
||||||
|
ooxmlExtensions = map[string]struct{}{
|
||||||
|
".docx": {}, ".docm": {}, ".dotx": {}, ".dotm": {},
|
||||||
|
".xlsx": {}, ".xlsm": {}, ".xltx": {}, ".xltm": {},
|
||||||
|
".pptx": {}, ".pptm": {}, ".potx": {}, ".potm": {},
|
||||||
|
".ppsx": {}, ".ppsm": {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// odfManifestSizeLimit caps how much of an ODF manifest is read. The manifest
|
||||||
|
// is a few kilobytes in practice; the cap stops a crafted archive from
|
||||||
|
// exhausting memory through its decompressed size.
|
||||||
|
const odfManifestSizeLimit = 1 << 20
|
||||||
|
|
||||||
|
// DetectPasswordProtection reports whether the document at path is encrypted.
|
||||||
|
//
|
||||||
|
// Detection is advisory and never fails: an unreadable file, an unknown format
|
||||||
|
// or a malformed archive all yield [PasswordProtectionUnknown]. It exists to
|
||||||
|
// refine the diagnosis of a conversion that already failed, since LibreOffice's
|
||||||
|
// exit codes do not distinguish a missing password from a crash.
|
||||||
|
func DetectPasswordProtection(path string) PasswordProtection {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return PasswordProtectionUnknown
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = f.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
magic := make([]byte, 8)
|
||||||
|
n, err := io.ReadFull(f, magic)
|
||||||
|
if err != nil && n < len(zipMagic) {
|
||||||
|
return PasswordProtectionUnknown
|
||||||
|
}
|
||||||
|
magic = magic[:n]
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case bytes.HasPrefix(magic, ole2Magic):
|
||||||
|
if _, ok := ooxmlExtensions[strings.ToLower(filepath.Ext(path))]; ok {
|
||||||
|
return PasswordProtectionRequired
|
||||||
|
}
|
||||||
|
// A legacy binary document is a compound file whether or not it is
|
||||||
|
// encrypted; its encryption lives in a stream this cannot cheaply read.
|
||||||
|
return PasswordProtectionUnknown
|
||||||
|
case bytes.HasPrefix(magic, zipMagic):
|
||||||
|
return detectZipPasswordProtection(f)
|
||||||
|
default:
|
||||||
|
// Flat XML (.fodt), RTF, CSV and everything else carry no encryption.
|
||||||
|
return PasswordProtectionUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectZipPasswordProtection inspects a ZIP package. ODF keeps META-INF/manifest.xml
|
||||||
|
// in cleartext even when encrypted, declaring each encrypted entry. An OOXML
|
||||||
|
// package has no manifest, and reaching this point already proves it is not an
|
||||||
|
// MS-OFFCRYPTO container, so it opens without a password.
|
||||||
|
func detectZipPasswordProtection(f *os.File) PasswordProtection {
|
||||||
|
size, err := f.Seek(0, io.SeekEnd)
|
||||||
|
if err != nil {
|
||||||
|
return PasswordProtectionUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := zip.NewReader(f, size)
|
||||||
|
if err != nil {
|
||||||
|
return PasswordProtectionUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, err := r.Open("META-INF/manifest.xml")
|
||||||
|
if err != nil {
|
||||||
|
// No manifest: an OOXML package, or a ZIP that is not an office
|
||||||
|
// document at all. Neither is encrypted.
|
||||||
|
return PasswordProtectionNone
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = manifest.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
content, err := io.ReadAll(io.LimitReader(manifest, odfManifestSizeLimit))
|
||||||
|
if err != nil {
|
||||||
|
return PasswordProtectionUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per OpenDocument 1.3 part 3, section 4.16, an encrypted entry carries a
|
||||||
|
// <manifest:encryption-data> child.
|
||||||
|
if bytes.Contains(content, []byte("encryption-data")) {
|
||||||
|
return PasswordProtectionRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
return PasswordProtectionNone
|
||||||
|
}
|
||||||
193
pkg/modules/libreoffice/api/protection_test.go
Normal file
193
pkg/modules/libreoffice/api/protection_test.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeFile writes content to a new file named name inside dir and returns its
|
||||||
|
// path.
|
||||||
|
func writeFile(t *testing.T, dir, name string, content []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
err := os.WriteFile(path, content, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeZip builds a ZIP archive from entries and returns its path.
|
||||||
|
func writeZip(t *testing.T, dir, name string, entries map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
w := zip.NewWriter(buf)
|
||||||
|
|
||||||
|
for entryName, content := range entries {
|
||||||
|
f, err := w.Create(entryName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create zip entry %s: %v", entryName, err)
|
||||||
|
}
|
||||||
|
_, err = f.Write([]byte(content))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write zip entry %s: %v", entryName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := w.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close zip writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return writeFile(t, dir, name, buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectPasswordProtection(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
ole2 := func(name string) string {
|
||||||
|
return writeFile(t, dir, name, append(ole2Magic, bytes.Repeat([]byte{0x00}, 64)...))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
want PasswordProtection
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "encrypted OOXML is a compound file",
|
||||||
|
path: ole2("encrypted.docx"),
|
||||||
|
want: PasswordProtectionRequired,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extension casing is ignored",
|
||||||
|
path: ole2("encrypted.DOCX"),
|
||||||
|
want: PasswordProtectionRequired,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "encrypted spreadsheet",
|
||||||
|
path: ole2("encrypted.xlsx"),
|
||||||
|
want: PasswordProtectionRequired,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "legacy binary document is inconclusive",
|
||||||
|
path: ole2("legacy.doc"),
|
||||||
|
want: PasswordProtectionUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain OOXML package",
|
||||||
|
path: writeZip(t, dir, "plain.docx", map[string]string{
|
||||||
|
"[Content_Types].xml": "<Types/>",
|
||||||
|
"word/document.xml": "<w:document/>",
|
||||||
|
}),
|
||||||
|
want: PasswordProtectionNone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "encrypted ODF declares encryption-data in its manifest",
|
||||||
|
path: writeZip(t, dir, "encrypted.odt", map[string]string{
|
||||||
|
"mimetype": "application/vnd.oasis.opendocument.text",
|
||||||
|
"META-INF/manifest.xml": `<manifest:manifest><manifest:file-entry><manifest:encryption-data manifest:checksum="x"/></manifest:file-entry></manifest:manifest>`,
|
||||||
|
"content.xml": "<office:document-content/>",
|
||||||
|
}),
|
||||||
|
want: PasswordProtectionRequired,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain ODF has a manifest without encryption-data",
|
||||||
|
path: writeZip(t, dir, "plain.odt", map[string]string{
|
||||||
|
"mimetype": "application/vnd.oasis.opendocument.text",
|
||||||
|
"META-INF/manifest.xml": `<manifest:manifest><manifest:file-entry manifest:full-path="/"/></manifest:manifest>`,
|
||||||
|
"content.xml": "<office:document-content/>",
|
||||||
|
}),
|
||||||
|
want: PasswordProtectionNone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "flat XML carries no encryption",
|
||||||
|
path: writeFile(t, dir, "flat.fodt", []byte("<?xml version=\"1.0\"?><office:document/>")),
|
||||||
|
want: PasswordProtectionUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain text",
|
||||||
|
path: writeFile(t, dir, "notes.txt", []byte("hello")),
|
||||||
|
want: PasswordProtectionUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file shorter than any magic",
|
||||||
|
path: writeFile(t, dir, "tiny.docx", []byte{0x50}),
|
||||||
|
want: PasswordProtectionUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty file",
|
||||||
|
path: writeFile(t, dir, "empty.docx", nil),
|
||||||
|
want: PasswordProtectionUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "truncated archive",
|
||||||
|
path: writeFile(t, dir, "truncated.docx", append(zipMagic, bytes.Repeat([]byte{0x00}, 32)...)),
|
||||||
|
want: PasswordProtectionUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-existent path",
|
||||||
|
path: filepath.Join(dir, "does-not-exist.docx"),
|
||||||
|
want: PasswordProtectionUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "directory",
|
||||||
|
path: dir,
|
||||||
|
want: PasswordProtectionUnknown,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := DetectPasswordProtection(tc.path); got != tc.want {
|
||||||
|
t.Errorf("DetectPasswordProtection(%s) = %d, want %d", tc.path, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDetectPasswordProtection_Fixtures anchors detection to the same documents
|
||||||
|
// the integration scenarios upload, so a fixture swap cannot silently flip a
|
||||||
|
// status code.
|
||||||
|
func TestDetectPasswordProtection_Fixtures(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
path string
|
||||||
|
want PasswordProtection
|
||||||
|
}{
|
||||||
|
{"../../../../test/integration/testdata/protected_page_1.docx", PasswordProtectionRequired},
|
||||||
|
{"../../../../test/integration/testdata/page_1.docx", PasswordProtectionNone},
|
||||||
|
} {
|
||||||
|
t.Run(filepath.Base(tc.path), func(t *testing.T) {
|
||||||
|
if _, err := os.Stat(tc.path); err != nil {
|
||||||
|
t.Skipf("fixture unavailable: %v", err)
|
||||||
|
}
|
||||||
|
if got := DetectPasswordProtection(tc.path); got != tc.want {
|
||||||
|
t.Errorf("DetectPasswordProtection(%s) = %d, want %d", tc.path, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDetectPasswordProtection_OversizedManifest verifies that a manifest far
|
||||||
|
// larger than the cap still yields a verdict through a bounded read.
|
||||||
|
func TestDetectPasswordProtection_OversizedManifest(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Well past odfManifestSizeLimit, and highly compressible, so the archive
|
||||||
|
// on disk stays small.
|
||||||
|
filler := strings.Repeat("<manifest:file-entry manifest:full-path=\"pad\"/>", 200_000)
|
||||||
|
|
||||||
|
path := writeZip(t, dir, "oversized.odt", map[string]string{
|
||||||
|
"mimetype": "application/vnd.oasis.opendocument.text",
|
||||||
|
"META-INF/manifest.xml": "<manifest:manifest>" + filler + "</manifest:manifest>",
|
||||||
|
})
|
||||||
|
|
||||||
|
if got := DetectPasswordProtection(path); got != PasswordProtectionNone {
|
||||||
|
t.Errorf("DetectPasswordProtection(oversized) = %d, want %d", got, PasswordProtectionNone)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,16 +15,18 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dlclark/regexp2"
|
"github.com/dlclark/regexp2"
|
||||||
|
"golang.org/x/net/http/httpproxy"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// outboundProxyOptions configures a [libreOfficeProxy].
|
// outboundProxyOptions configures a [libreOfficeProxy].
|
||||||
type outboundProxyOptions struct {
|
type outboundProxyOptions struct {
|
||||||
allowList []*regexp2.Regexp
|
allowList []*regexp2.Regexp
|
||||||
denyList []*regexp2.Regexp
|
denyList []*regexp2.Regexp
|
||||||
denyPrivateIPs bool
|
denyPrivateIPs bool
|
||||||
denyPublicIPs bool
|
denyPublicIPs bool
|
||||||
|
enableEnvironmentProxy bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// libreOfficeProxy is an HTTP/HTTPS forward proxy that LibreOffice routes
|
// libreOfficeProxy is an HTTP/HTTPS forward proxy that LibreOffice routes
|
||||||
@@ -45,6 +47,12 @@ type libreOfficeProxy struct {
|
|||||||
opts outboundProxyOptions
|
opts outboundProxyOptions
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
|
||||||
|
// upstreamProxy resolves the upstream (corporate) proxy for a destination
|
||||||
|
// URL from the standard proxy environment variables, or returns a nil URL
|
||||||
|
// to connect directly. Nil unless the operator opted into proxy-
|
||||||
|
// environment honoring. See https://github.com/gotenberg/gotenberg/issues/1592.
|
||||||
|
upstreamProxy func(*url.URL) (*url.URL, error)
|
||||||
|
|
||||||
stopOnce sync.Once
|
stopOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +72,15 @@ func newLibreOfficeProxy(logger *slog.Logger, opts outboundProxyOptions) (*libre
|
|||||||
|
|
||||||
p := &libreOfficeProxy{
|
p := &libreOfficeProxy{
|
||||||
listener: listener,
|
listener: listener,
|
||||||
client: gotenberg.NewOutboundHttpClient(0, opts.allowList, opts.denyList, decideOpts...),
|
client: gotenberg.NewOutboundHttpClient(0, opts.allowList, opts.denyList, opts.enableEnvironmentProxy, decideOpts...),
|
||||||
opts: opts,
|
opts: opts,
|
||||||
logger: logger.With(slog.String("logger", "libreoffice-proxy")),
|
logger: logger.With(slog.String("logger", "libreoffice-proxy")),
|
||||||
}
|
}
|
||||||
|
if opts.enableEnvironmentProxy {
|
||||||
|
// Honor the standard proxy environment variables, credentials
|
||||||
|
// included. httpproxy reads the environment now and applies NO_PROXY.
|
||||||
|
p.upstreamProxy = httpproxy.FromEnvironment().ProxyFunc()
|
||||||
|
}
|
||||||
p.server = &http.Server{
|
p.server = &http.Server{
|
||||||
Handler: p,
|
Handler: p,
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
@@ -182,8 +195,25 @@ func (p *libreOfficeProxy) handleConnect(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When the operator routes egress through an authenticated proxy, soffice
|
||||||
|
// cannot supply the credentials, so the proxy performs the CONNECT (and
|
||||||
|
// authentication) upstream. The decision above still gated the destination.
|
||||||
|
var proxyURL *url.URL
|
||||||
|
if p.upstreamProxy != nil {
|
||||||
|
proxyURL, err = p.upstreamProxy(&url.URL{Scheme: "https", Host: net.JoinHostPort(host, port)})
|
||||||
|
if err != nil {
|
||||||
|
p.logger.WarnContext(r.Context(), fmt.Sprintf("LibreOffice proxy resolve upstream proxy for '%s': %s", rawURL, err))
|
||||||
|
http.Error(w, "proxy: upstream proxy error", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var dest net.Conn
|
var dest net.Conn
|
||||||
switch {
|
switch {
|
||||||
|
case proxyURL != nil:
|
||||||
|
dest, err = gotenberg.DialThroughProxy(r.Context(), proxyURL, r.Host, func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return net.DialTimeout(network, addr, 10*time.Second)
|
||||||
|
})
|
||||||
case len(decision.Pinned) > 0:
|
case len(decision.Pinned) > 0:
|
||||||
dest, err = gotenberg.DialPinned(r.Context(), "tcp", decision.Pinned, port)
|
dest, err = gotenberg.DialPinned(r.Context(), "tcp", decision.Pinned, port)
|
||||||
default:
|
default:
|
||||||
|
|||||||
230
pkg/modules/libreoffice/api/singlepagesheets.go
Normal file
230
pkg/modules/libreoffice/api/singlepagesheets.go
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// topLeftCellAttr matches the topLeftCell attribute that an OOXML worksheet
|
||||||
|
// uses (on <sheetView> and, for frozen panes, <pane>) to store the cell that
|
||||||
|
// was at the top-left of the window when the workbook was saved.
|
||||||
|
var topLeftCellAttr = regexp.MustCompile(` topLeftCell="[^"]*"`)
|
||||||
|
|
||||||
|
// maxDecompressedWorksheet bounds how much a single worksheet may decompress to
|
||||||
|
// while rewriting it. It guards against a decompression bomb and keeps memory
|
||||||
|
// predictable. A worksheet larger than this is left untouched, so a pathological
|
||||||
|
// workbook falls back to the original file rather than being rewritten.
|
||||||
|
const maxDecompressedWorksheet = 128 << 20 // 128 MiB
|
||||||
|
|
||||||
|
// resetCalcScrollPosition returns a path to a copy of inputPath whose worksheet
|
||||||
|
// scroll positions have been reset to the top-left cell, or inputPath unchanged
|
||||||
|
// when the reset does not apply or cannot be performed safely.
|
||||||
|
//
|
||||||
|
// LibreOffice's SinglePageSheets export starts each single page at the sheet's
|
||||||
|
// saved topLeftCell, dropping every row and column above and to the left of it.
|
||||||
|
// A workbook saved scrolled away from A1 therefore renders truncated. Removing
|
||||||
|
// the attribute before the conversion makes the whole used range render.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1222.
|
||||||
|
//
|
||||||
|
// The function never fails the conversion. On a non-xlsx input, a workbook that
|
||||||
|
// carries no scroll position, or any read, rewrite or validation error, it
|
||||||
|
// returns the original path so a malformed rewrite can never reach LibreOffice.
|
||||||
|
func resetCalcScrollPosition(ctx context.Context, logger *slog.Logger, inputPath string) string {
|
||||||
|
// Resolve the extension to a literal so the sanitized filename is never
|
||||||
|
// derived from the (user-controlled) upload name.
|
||||||
|
var ext string
|
||||||
|
switch strings.ToLower(filepath.Ext(inputPath)) {
|
||||||
|
case ".xlsx":
|
||||||
|
ext = ".xlsx"
|
||||||
|
case ".xlsm":
|
||||||
|
ext = ".xlsm"
|
||||||
|
default:
|
||||||
|
return inputPath
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := os.ReadFile(inputPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: read input: %s; using the original file", err))
|
||||||
|
return inputPath
|
||||||
|
}
|
||||||
|
|
||||||
|
out, changed, err := stripWorksheetScrollPosition(src)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: %s; using the original file", err))
|
||||||
|
return inputPath
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
// The common case: nothing was saved scrolled, so nothing to do.
|
||||||
|
return inputPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rewrite that dropped, renamed or corrupted an entry must never reach
|
||||||
|
// LibreOffice; fall back to the original workbook if it does not round-trip.
|
||||||
|
if err = validateWorkbook(src, out); err != nil {
|
||||||
|
logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: %s; using the original file", err))
|
||||||
|
return inputPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the sanitized copy alongside the input, inside the request working
|
||||||
|
// directory that LibreOffice already reads from. The pattern is constant,
|
||||||
|
// so the resulting name carries no user-controlled path component.
|
||||||
|
dst, err := os.CreateTemp(filepath.Dir(inputPath), "singlepagesheets-*"+ext)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: create sanitized file: %s; using the original file", err))
|
||||||
|
return inputPath
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
_, err = dst.Write(out)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(dst.Name())
|
||||||
|
logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: write sanitized file: %s; using the original file", err))
|
||||||
|
return inputPath
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugContext(ctx, "reset calc scroll position: cleared worksheet topLeftCell for SinglePageSheets export")
|
||||||
|
return dst.Name()
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripWorksheetScrollPosition rewrites the worksheet XML entries of an xlsx
|
||||||
|
// workbook, removing the topLeftCell attribute, and reports whether anything
|
||||||
|
// changed. Every non-worksheet entry, and every worksheet that does not carry
|
||||||
|
// the attribute, is copied byte-for-byte without recompression.
|
||||||
|
func stripWorksheetScrollPosition(src []byte) ([]byte, bool, error) {
|
||||||
|
reader, err := zip.NewReader(bytes.NewReader(src), int64(len(src)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("open workbook: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writer := zip.NewWriter(&buf)
|
||||||
|
changed := false
|
||||||
|
|
||||||
|
for _, file := range reader.File {
|
||||||
|
rewritten, ok, err := rewriteWorksheet(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
// Recompress only the worksheets that actually changed.
|
||||||
|
header := file.FileHeader
|
||||||
|
header.Method = zip.Deflate
|
||||||
|
w, err := writer.CreateHeader(&header)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("write worksheet %q: %w", file.Name, err)
|
||||||
|
}
|
||||||
|
_, err = w.Write(rewritten)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("write worksheet %q: %w", file.Name, err)
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
err = copyZipEntry(writer, file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writer.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("finalize workbook: %w", err)
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
return buf.Bytes(), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rewriteWorksheet returns file's contents with topLeftCell removed, and
|
||||||
|
// whether file is a worksheet that carried the attribute. A worksheet without
|
||||||
|
// the attribute, or any other entry, returns ok false so the caller copies it
|
||||||
|
// verbatim.
|
||||||
|
func rewriteWorksheet(file *zip.File) ([]byte, bool, error) {
|
||||||
|
if !strings.HasPrefix(file.Name, "xl/worksheets/") || !strings.HasSuffix(strings.ToLower(file.Name), ".xml") {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rc, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("open worksheet %q: %w", file.Name, err)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
// Read at most maxDecompressedWorksheet+1 bytes so a decompression bomb
|
||||||
|
// cannot exhaust memory; a genuine overflow aborts the rewrite.
|
||||||
|
data, err := io.ReadAll(io.LimitReader(rc, maxDecompressedWorksheet+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("read worksheet %q: %w", file.Name, err)
|
||||||
|
}
|
||||||
|
if len(data) > maxDecompressedWorksheet {
|
||||||
|
return nil, false, fmt.Errorf("worksheet %q exceeds %d bytes", file.Name, maxDecompressedWorksheet)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Contains(data, []byte("topLeftCell")) {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
return topLeftCellAttr.ReplaceAll(data, nil), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyZipEntry writes file into writer without decompressing and recompressing
|
||||||
|
// it, preserving its exact bytes.
|
||||||
|
func copyZipEntry(writer *zip.Writer, file *zip.File) error {
|
||||||
|
w, err := writer.CreateRaw(&file.FileHeader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("copy entry %q: %w", file.Name, err)
|
||||||
|
}
|
||||||
|
rc, err := file.OpenRaw()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open entry %q: %w", file.Name, err)
|
||||||
|
}
|
||||||
|
_, err = io.Copy(w, rc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("copy entry %q: %w", file.Name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateWorkbook checks that out reopens as a zip holding exactly the same
|
||||||
|
// entry names as src, rejecting a rewrite that lost, renamed or added an entry
|
||||||
|
// or produced a broken central directory. The entry payloads themselves are
|
||||||
|
// not re-read: unchanged entries are copied byte-for-byte from a workbook that
|
||||||
|
// already parsed, and rewritten worksheets are produced by the standard library
|
||||||
|
// writer, so re-decompressing everything would only add a decompression-bomb
|
||||||
|
// surface without catching a failure this transform can introduce.
|
||||||
|
func validateWorkbook(src, out []byte) error {
|
||||||
|
original, err := zip.NewReader(bytes.NewReader(src), int64(len(src)))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reopen original workbook: %w", err)
|
||||||
|
}
|
||||||
|
rewritten, err := zip.NewReader(bytes.NewReader(out), int64(len(out)))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reopen rewritten workbook: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rewritten.File) != len(original.File) {
|
||||||
|
return fmt.Errorf("entry count changed from %d to %d", len(original.File), len(rewritten.File))
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make(map[string]struct{}, len(original.File))
|
||||||
|
for _, file := range original.File {
|
||||||
|
names[file.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, file := range rewritten.File {
|
||||||
|
_, ok := names[file.Name]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unexpected entry %q", file.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
216
pkg/modules/libreoffice/api/singlepagesheets_test.go
Normal file
216
pkg/modules/libreoffice/api/singlepagesheets_test.go
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildWorkbook packs entries into an in-memory xlsx-like zip.
|
||||||
|
func buildWorkbook(t *testing.T, entries map[string]string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := zip.NewWriter(&buf)
|
||||||
|
for name, content := range entries {
|
||||||
|
f, err := w.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create entry %q: %v", name, err)
|
||||||
|
}
|
||||||
|
_, err = f.Write([]byte(content))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write entry %q: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := w.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close workbook: %v", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func readEntry(t *testing.T, workbook []byte, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
r, err := zip.NewReader(bytes.NewReader(workbook), int64(len(workbook)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open workbook: %v", err)
|
||||||
|
}
|
||||||
|
for _, f := range r.File {
|
||||||
|
if f.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open entry %q: %v", name, err)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
data, err := io.ReadAll(rc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read entry %q: %v", name, err)
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
t.Fatalf("entry %q not found", name)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrolledSheet = `<?xml version="1.0"?><worksheet><dimension ref="A1:B83"/>` +
|
||||||
|
`<sheetViews><sheetView tabSelected="1" topLeftCell="A37" workbookViewId="0">` +
|
||||||
|
`<pane topLeftCell="A37"/><selection activeCell="A1" sqref="A1"/></sheetView></sheetViews>` +
|
||||||
|
`<sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData></worksheet>`
|
||||||
|
|
||||||
|
const topSheet = `<?xml version="1.0"?><worksheet><dimension ref="A1:B83"/>` +
|
||||||
|
`<sheetViews><sheetView tabSelected="1" workbookViewId="0"/></sheetViews>` +
|
||||||
|
`<sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData></worksheet>`
|
||||||
|
|
||||||
|
func TestStripWorksheetScrollPosition(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
workbook []byte
|
||||||
|
expectErr bool
|
||||||
|
expectChange bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "removes topLeftCell from sheetView and pane",
|
||||||
|
workbook: buildWorkbook(t, map[string]string{
|
||||||
|
"[Content_Types].xml": "<Types/>",
|
||||||
|
"xl/worksheets/sheet1.xml": scrolledSheet,
|
||||||
|
"xl/sharedStrings.xml": "<sst/>",
|
||||||
|
}),
|
||||||
|
expectChange: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "leaves a workbook without a saved scroll position untouched",
|
||||||
|
workbook: buildWorkbook(t, map[string]string{
|
||||||
|
"[Content_Types].xml": "<Types/>",
|
||||||
|
"xl/worksheets/sheet1.xml": topSheet,
|
||||||
|
}),
|
||||||
|
expectChange: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "only rewrites worksheet entries",
|
||||||
|
workbook: buildWorkbook(t, map[string]string{
|
||||||
|
"xl/worksheets/sheet1.xml": scrolledSheet,
|
||||||
|
// A stray topLeftCell elsewhere must not be touched.
|
||||||
|
"xl/workbook.xml": `<workbook topLeftCell="A9"/>`,
|
||||||
|
}),
|
||||||
|
expectChange: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "rejects a non-zip input",
|
||||||
|
workbook: []byte("not a zip file"),
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
out, changed, err := stripWorksheetScrollPosition(tc.workbook)
|
||||||
|
|
||||||
|
if tc.expectErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got nil")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if changed != tc.expectChange {
|
||||||
|
t.Fatalf("expected changed=%v, got %v", tc.expectChange, changed)
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rewrite must round-trip and hold the same entries.
|
||||||
|
if err = validateWorkbook(tc.workbook, out); err != nil {
|
||||||
|
t.Fatalf("rewritten workbook did not validate: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(readEntry(t, out, "xl/worksheets/sheet1.xml"), "topLeftCell") {
|
||||||
|
t.Fatalf("worksheet still contains topLeftCell")
|
||||||
|
}
|
||||||
|
// Non-worksheet entries are copied verbatim.
|
||||||
|
if _, ok := entryNames(t, out)["xl/workbook.xml"]; ok {
|
||||||
|
if got := readEntry(t, out, "xl/workbook.xml"); got != `<workbook topLeftCell="A9"/>` {
|
||||||
|
t.Fatalf("non-worksheet entry was modified: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func entryNames(t *testing.T, workbook []byte) map[string]struct{} {
|
||||||
|
t.Helper()
|
||||||
|
r, err := zip.NewReader(bytes.NewReader(workbook), int64(len(workbook)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open workbook: %v", err)
|
||||||
|
}
|
||||||
|
names := make(map[string]struct{}, len(r.File))
|
||||||
|
for _, f := range r.File {
|
||||||
|
names[f.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetCalcScrollPosition(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
writeFile := func(t *testing.T, name string, content []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), name)
|
||||||
|
err := os.WriteFile(path, content, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write %q: %v", name, err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("non-xlsx input is returned unchanged", func(t *testing.T) {
|
||||||
|
path := writeFile(t, "input.docx", []byte("whatever"))
|
||||||
|
if got := resetCalcScrollPosition(ctx, logger, path); got != path {
|
||||||
|
t.Fatalf("expected %q, got %q", path, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("workbook without a scroll position is returned unchanged", func(t *testing.T) {
|
||||||
|
path := writeFile(t, "input.xlsx", buildWorkbook(t, map[string]string{
|
||||||
|
"xl/worksheets/sheet1.xml": topSheet,
|
||||||
|
}))
|
||||||
|
if got := resetCalcScrollPosition(ctx, logger, path); got != path {
|
||||||
|
t.Fatalf("expected original path %q, got %q", path, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("corrupt xlsx falls back to the original path", func(t *testing.T) {
|
||||||
|
path := writeFile(t, "input.xlsx", []byte("PK\x03\x04 not really a zip"))
|
||||||
|
if got := resetCalcScrollPosition(ctx, logger, path); got != path {
|
||||||
|
t.Fatalf("expected fallback to %q, got %q", path, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("scrolled workbook yields a sanitized copy", func(t *testing.T) {
|
||||||
|
path := writeFile(t, "input.xlsx", buildWorkbook(t, map[string]string{
|
||||||
|
"[Content_Types].xml": "<Types/>",
|
||||||
|
"xl/worksheets/sheet1.xml": scrolledSheet,
|
||||||
|
}))
|
||||||
|
got := resetCalcScrollPosition(ctx, logger, path)
|
||||||
|
if got == path {
|
||||||
|
t.Fatalf("expected a sanitized copy, got the original path")
|
||||||
|
}
|
||||||
|
if filepath.Dir(got) != filepath.Dir(path) {
|
||||||
|
t.Fatalf("sanitized copy escaped the working directory: %q", got)
|
||||||
|
}
|
||||||
|
sanitized, err := os.ReadFile(got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read sanitized file: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(readEntry(t, sanitized, "xl/worksheets/sheet1.xml"), "topLeftCell") {
|
||||||
|
t.Fatalf("sanitized worksheet still contains topLeftCell")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -60,6 +60,11 @@ func (engine *LibreOfficePdfEngine) Flatten(ctx context.Context, logger *slog.Lo
|
|||||||
return fmt.Errorf("flatten PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
return fmt.Errorf("flatten PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OptimizeImages is not available in this implementation.
|
||||||
|
func (engine *LibreOfficePdfEngine) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
|
||||||
|
return fmt.Errorf("optimize PDF images with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
// Convert converts the given PDF to a specific PDF format. Currently, only the
|
// Convert converts the given PDF to a specific PDF format. Currently, only the
|
||||||
// PDF/A-1b, PDF/A-2b, PDF/A-3b and PDF/UA formats are available. If another
|
// PDF/A-1b, PDF/A-2b, PDF/A-3b and PDF/UA formats are available. If another
|
||||||
// PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported]
|
// PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported]
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import (
|
|||||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// unattributableFailureMessage is returned when LibreOffice fails and no
|
||||||
|
// client-supplied input is implicated. Its only format verb is the original
|
||||||
|
// filename.
|
||||||
|
const unattributableFailureMessage = "LibreOffice failed to convert the document '%s'. This is usually a resource issue: increase the container's memory and CPU, or reduce the document's size. The request is valid and may be retried."
|
||||||
|
|
||||||
// convertRoute returns an [api.Route] which can convert LibreOffice documents
|
// convertRoute returns an [api.Route] which can convert LibreOffice documents
|
||||||
// to PDF.
|
// to PDF.
|
||||||
func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) api.Route {
|
func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) api.Route {
|
||||||
@@ -32,13 +37,20 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
metadata := pdfengines.FormDataPdfMetadata(form, false)
|
||||||
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
encrypt := pdfengines.FormDataPdfEncrypt(form)
|
||||||
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
embedPaths := pdfengines.FormDataPdfEmbeds(form)
|
||||||
watermark := pdfengines.FormDataPdfWatermark(form, false)
|
watermarks, wErr := pdfengines.FormDataPdfWatermarks(form)
|
||||||
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
|
if wErr != nil {
|
||||||
stamp := pdfengines.FormDataPdfStamp(form, false)
|
return fmt.Errorf("form data watermarks: %w", wErr)
|
||||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
}
|
||||||
|
stamps, sErr := pdfengines.FormDataPdfStamps(form)
|
||||||
|
if sErr != nil {
|
||||||
|
return fmt.Errorf("form data stamps: %w", sErr)
|
||||||
|
}
|
||||||
|
var watermarkFiles, stampFiles []string
|
||||||
|
form.Watermarks(&watermarkFiles).Stamps(&stampFiles)
|
||||||
angle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
angle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||||
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||||
|
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
|
||||||
|
|
||||||
zeroValuedSplitMode := gotenberg.SplitMode{}
|
zeroValuedSplitMode := gotenberg.SplitMode{}
|
||||||
|
|
||||||
@@ -305,13 +317,13 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
return fmt.Errorf("validate form data: %w", err)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
|
err = pdfengines.BindWatermarkFiles(watermarks, watermarkFiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate watermark: %w", err)
|
return fmt.Errorf("bind watermark files: %w", err)
|
||||||
}
|
}
|
||||||
err = pdfengines.EnsureStampFile(&stamp, stampFile)
|
err = pdfengines.BindStampFiles(stamps, stampFiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate stamp: %w", err)
|
return fmt.Errorf("bind stamp files: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
|
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
|
||||||
@@ -333,7 +345,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
// requested. The conversion runs as a post-processing step below.
|
// requested. The conversion runs as a post-processing step below.
|
||||||
pdfFormats = pdfengines.FacturXPdfFormats(ctx, engine, facturX, pdfFormats, true, nil)
|
pdfFormats = pdfengines.FacturXPdfFormats(ctx, engine, facturX, pdfFormats, true, nil)
|
||||||
|
|
||||||
hasPostProcessing := watermark.Source != "" || stamp.Source != "" || angle != 0 ||
|
hasPostProcessing := len(watermarks) > 0 || len(stamps) > 0 || angle != 0 ||
|
||||||
len(embedPaths) > 0 || len(metadata) > 0 || flatten || facturX.ConformanceLevel != ""
|
len(embedPaths) > 0 || len(metadata) > 0 || flatten || facturX.ConformanceLevel != ""
|
||||||
|
|
||||||
outputPaths := make([]string, len(inputPaths))
|
outputPaths := make([]string, len(inputPaths))
|
||||||
@@ -405,20 +417,52 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, libreofficeapi.ErrUnoException) {
|
filename := ctx.OriginalFilename(inputPath)
|
||||||
|
|
||||||
|
if errors.Is(err, libreofficeapi.ErrIoException) || errors.Is(err, libreofficeapi.ErrIllegalArgumentException) {
|
||||||
return api.WrapError(
|
return api.WrapError(
|
||||||
fmt.Errorf("convert to PDF: %w", err),
|
fmt.Errorf("convert to PDF: %w", err),
|
||||||
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice failed to process a document: possible causes include malformed page ranges '%s' (nativePageRanges), or, if a password has been provided, it may not be required. In any case, the exact cause is uncertain.", options.PageRanges)),
|
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice could not read the document '%s'. Ensure the file is not corrupted and that its extension matches its actual format.", filename)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, libreofficeapi.ErrRuntimeException) {
|
if errors.Is(err, libreofficeapi.ErrCannotConvertException) {
|
||||||
return api.WrapError(
|
return api.WrapError(
|
||||||
fmt.Errorf("convert to PDF: %w", err),
|
fmt.Errorf("convert to PDF: %w", err),
|
||||||
api.NewSentinelHttpError(http.StatusBadRequest, "LibreOffice failed to process a document: a password may be required, or, if one has been given, it is invalid. In any case, the exact cause is uncertain."),
|
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice read the document '%s' but could not convert it to PDF. The document may be corrupted or rely on an unsupported feature.", filename)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exit codes 5 and 6 name the UNO exception class that was
|
||||||
|
// caught, not a cause: both cover a client mistake and a
|
||||||
|
// LibreOffice crash. Blame the client only when one of its
|
||||||
|
// inputs is actually implicated, since the server is the
|
||||||
|
// only remaining explanation otherwise. Password evidence
|
||||||
|
// outranks page ranges: a password failure aborts on import,
|
||||||
|
// before the export filter applies any page range.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1588.
|
||||||
|
if errors.Is(err, libreofficeapi.ErrUnoException) || errors.Is(err, libreofficeapi.ErrRuntimeException) {
|
||||||
|
protection := libreofficeapi.DetectPasswordProtection(inputPath)
|
||||||
|
|
||||||
|
var sentinel api.SentinelHttpError
|
||||||
|
switch {
|
||||||
|
case protection == libreofficeapi.PasswordProtectionRequired && options.Password == "":
|
||||||
|
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("The document '%s' is password-protected. Provide its password in the 'password' form field.", filename))
|
||||||
|
case protection == libreofficeapi.PasswordProtectionRequired:
|
||||||
|
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("The password for the document '%s' is incorrect. Check the 'password' form field.", filename))
|
||||||
|
case protection == libreofficeapi.PasswordProtectionNone && options.Password != "":
|
||||||
|
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("The document '%s' is not password-protected. Remove the 'password' form field.", filename))
|
||||||
|
case options.Password != "":
|
||||||
|
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice could not open the document '%s' with the given password. Check the 'password' form field, and omit it if the document is not password-protected.", filename))
|
||||||
|
case errors.Is(err, libreofficeapi.ErrUnoException) && options.PageRanges != "":
|
||||||
|
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice could not apply the page ranges '%s' to the document '%s'. Check the 'nativePageRanges' form field; valid values look like '1-4', '2' or '1,3,5-7'.", options.PageRanges, filename))
|
||||||
|
default:
|
||||||
|
sentinel = api.NewSentinelHttpError(http.StatusInternalServerError, fmt.Sprintf(unattributableFailureMessage, filename))
|
||||||
|
}
|
||||||
|
|
||||||
|
return api.WrapError(fmt.Errorf("convert to PDF: %w", err), sentinel)
|
||||||
|
}
|
||||||
|
|
||||||
return fmt.Errorf("convert to PDF: %w", err)
|
return fmt.Errorf("convert to PDF: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -456,12 +500,12 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.WatermarkStub(ctx, engine, watermark, outputPaths)
|
err = pdfengines.WatermarkStub(ctx, engine, watermarks, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("watermark PDFs: %w", err)
|
return fmt.Errorf("watermark PDFs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pdfengines.StampStub(ctx, engine, stamp, outputPaths)
|
err = pdfengines.StampStub(ctx, engine, stamps, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("stamp PDFs: %w", err)
|
return fmt.Errorf("stamp PDFs: %w", err)
|
||||||
}
|
}
|
||||||
@@ -478,6 +522,11 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = pdfengines.OptimizeStub(ctx, engine, optimizeImages, imageQuality, outputPaths)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("optimize PDF images: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
needsConvertStub := !nativePdfFormats ||
|
needsConvertStub := !nativePdfFormats ||
|
||||||
(nativePdfFormats && splitMode != zeroValuedSplitMode) ||
|
(nativePdfFormats && splitMode != zeroValuedSplitMode) ||
|
||||||
(nativePdfFormats && hasPostProcessing)
|
(nativePdfFormats && hasPostProcessing)
|
||||||
|
|||||||
241
pkg/modules/libreoffice/routes_test.go
Normal file
241
pkg/modules/libreoffice/routes_test.go
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
package libreoffice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||||
|
libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// compoundFile writes a document whose header marks it as a compound file. Over
|
||||||
|
// an OOXML extension, that means an encrypted payload.
|
||||||
|
func compoundFile(t *testing.T, dir, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
content := append(
|
||||||
|
[]byte{0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1},
|
||||||
|
bytes.Repeat([]byte{0x00}, 64)...,
|
||||||
|
)
|
||||||
|
|
||||||
|
return writeTestFile(t, dir, name, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// zipPackage writes a minimal, unencrypted OOXML package.
|
||||||
|
func zipPackage(t *testing.T, dir, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
w := zip.NewWriter(buf)
|
||||||
|
|
||||||
|
f, err := w.Create("[Content_Types].xml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create zip entry: %v", err)
|
||||||
|
}
|
||||||
|
_, err = f.Write([]byte("<Types/>"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write zip entry: %v", err)
|
||||||
|
}
|
||||||
|
err = w.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close zip writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return writeTestFile(t, dir, name, buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTestFile(t *testing.T, dir, name string, content []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
err := os.WriteFile(path, content, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConvertRoute_FailureStatus pins the branch table that decides whether a
|
||||||
|
// LibreOffice failure is the client's fault. See
|
||||||
|
// https://github.com/gotenberg/gotenberg/issues/1588.
|
||||||
|
func TestConvertRoute_FailureStatus(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
var (
|
||||||
|
protected = compoundFile(t, dir, "protected_page_1.docx")
|
||||||
|
plain = zipPackage(t, dir, "page_1.docx")
|
||||||
|
legacy = compoundFile(t, dir, "legacy.doc")
|
||||||
|
corrupted = writeTestFile(t, dir, "corrupted.docx", []byte("not a document"))
|
||||||
|
unreachable = filepath.Join(dir, "vanished.docx")
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
inputPath string
|
||||||
|
values map[string][]string
|
||||||
|
err error
|
||||||
|
wantStatus int
|
||||||
|
wantBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "encrypted document, no password",
|
||||||
|
inputPath: protected,
|
||||||
|
err: libreofficeapi.ErrRuntimeException,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "The document 'protected_page_1.docx' is password-protected. Provide its password in the 'password' form field.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "encrypted document, wrong password",
|
||||||
|
inputPath: protected,
|
||||||
|
values: map[string][]string{"password": {"bar"}},
|
||||||
|
err: libreofficeapi.ErrRuntimeException,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "The password for the document 'protected_page_1.docx' is incorrect. Check the 'password' form field.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unencrypted document, password supplied",
|
||||||
|
inputPath: plain,
|
||||||
|
values: map[string][]string{"password": {"foo"}},
|
||||||
|
err: libreofficeapi.ErrUnoException,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "The document 'page_1.docx' is not password-protected. Remove the 'password' form field.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inconclusive document, password supplied",
|
||||||
|
inputPath: legacy,
|
||||||
|
values: map[string][]string{"password": {"foo"}},
|
||||||
|
err: libreofficeapi.ErrUnoException,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "LibreOffice could not open the document 'legacy.doc' with the given password. Check the 'password' form field, and omit it if the document is not password-protected.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed page ranges",
|
||||||
|
inputPath: plain,
|
||||||
|
values: map[string][]string{"nativePageRanges": {"foo"}},
|
||||||
|
err: libreofficeapi.ErrUnoException,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "LibreOffice could not apply the page ranges 'foo' to the document 'page_1.docx'. Check the 'nativePageRanges' form field; valid values look like '1-4', '2' or '1,3,5-7'.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "password evidence outranks page ranges",
|
||||||
|
inputPath: protected,
|
||||||
|
values: map[string][]string{"nativePageRanges": {"1-2"}},
|
||||||
|
err: libreofficeapi.ErrUnoException,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "The document 'protected_page_1.docx' is password-protected. Provide its password in the 'password' form field.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "page ranges do not excuse a runtime exception",
|
||||||
|
inputPath: plain,
|
||||||
|
values: map[string][]string{"nativePageRanges": {"1-2"}},
|
||||||
|
err: libreofficeapi.ErrRuntimeException,
|
||||||
|
wantStatus: http.StatusInternalServerError,
|
||||||
|
wantBody: fmt.Sprintf(unattributableFailureMessage, "page_1.docx"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nothing implicated, uno exception",
|
||||||
|
inputPath: plain,
|
||||||
|
err: libreofficeapi.ErrUnoException,
|
||||||
|
wantStatus: http.StatusInternalServerError,
|
||||||
|
wantBody: fmt.Sprintf(unattributableFailureMessage, "page_1.docx"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nothing implicated, runtime exception",
|
||||||
|
inputPath: plain,
|
||||||
|
err: libreofficeapi.ErrRuntimeException,
|
||||||
|
wantStatus: http.StatusInternalServerError,
|
||||||
|
wantBody: fmt.Sprintf(unattributableFailureMessage, "page_1.docx"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "detection cannot read the document",
|
||||||
|
inputPath: unreachable,
|
||||||
|
err: libreofficeapi.ErrUnoException,
|
||||||
|
wantStatus: http.StatusInternalServerError,
|
||||||
|
wantBody: fmt.Sprintf(unattributableFailureMessage, "vanished.docx"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unreadable source",
|
||||||
|
inputPath: corrupted,
|
||||||
|
err: libreofficeapi.ErrIoException,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "LibreOffice could not read the document 'corrupted.docx'. Ensure the file is not corrupted and that its extension matches its actual format.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rejected source",
|
||||||
|
inputPath: corrupted,
|
||||||
|
err: libreofficeapi.ErrIllegalArgumentException,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "LibreOffice could not read the document 'corrupted.docx'. Ensure the file is not corrupted and that its extension matches its actual format.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unconvertible document",
|
||||||
|
inputPath: corrupted,
|
||||||
|
err: libreofficeapi.ErrCannotConvertException,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "LibreOffice read the document 'corrupted.docx' but could not convert it to PDF. The document may be corrupted or rely on an unsupported feature.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "core dumped past the retry cap",
|
||||||
|
inputPath: plain,
|
||||||
|
err: libreofficeapi.ErrCoreDumped,
|
||||||
|
wantStatus: http.StatusInternalServerError,
|
||||||
|
wantBody: http.StatusText(http.StatusInternalServerError),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unmapped exit code",
|
||||||
|
inputPath: plain,
|
||||||
|
err: fmt.Errorf("convert to PDF: exit status 7"),
|
||||||
|
wantStatus: http.StatusInternalServerError,
|
||||||
|
wantBody: http.StatusText(http.StatusInternalServerError),
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
|
ctx.SetDirPath(dir)
|
||||||
|
ctx.SetFiles(map[string]string{filepath.Base(tc.inputPath): tc.inputPath})
|
||||||
|
ctx.SetValues(tc.values)
|
||||||
|
ctx.SetLogger(slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
|
uno := &libreofficeapi.ApiMock{
|
||||||
|
ExtensionsMock: func() []string {
|
||||||
|
return []string{".docx", ".doc"}
|
||||||
|
},
|
||||||
|
PdfMock: func(_ context.Context, _ *slog.Logger, _, _ string, _ libreofficeapi.Options) error {
|
||||||
|
// Mirror the wrapping done by [libreofficeapi.Api.Pdf].
|
||||||
|
return fmt.Errorf("supervisor run task: %w", tc.err)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
c := echo.New().NewContext(
|
||||||
|
httptest.NewRequest(http.MethodPost, "/forms/libreoffice/convert", nil),
|
||||||
|
httptest.NewRecorder(),
|
||||||
|
)
|
||||||
|
c.Set("context", ctx.Context)
|
||||||
|
|
||||||
|
err := convertRoute(uno, new(gotenberg.PdfEngineMock)).Handler(c)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error, got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
status, message := api.ParseError(err)
|
||||||
|
if status != tc.wantStatus {
|
||||||
|
t.Errorf("status = %d, want %d (message: %s)", status, tc.wantStatus, message)
|
||||||
|
}
|
||||||
|
if message != tc.wantBody {
|
||||||
|
t.Errorf("message =\n%s\nwant\n%s", message, tc.wantBody)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
321
pkg/modules/pdfcpu/optimize.go
Normal file
321
pkg/modules/pdfcpu/optimize.go
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
package pdfcpu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
_ "image/png" // Register the PNG decoder: pdfcpu extracts FlateDecode images as PNG.
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel/codes"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// minOptimizeImageSize is the smallest encoded image worth re-encoding. Smaller
|
||||||
|
// images (thumbnails, icons, and line art that FlateDecode already keeps tiny)
|
||||||
|
// are left untouched: a JPEG pass would add artifacts for little or no gain.
|
||||||
|
const minOptimizeImageSize = 30 << 10 // 30 KiB
|
||||||
|
|
||||||
|
// pdfcpuListRowID matches the image Id (e.g. "X6") in a `pdfcpu images extract`
|
||||||
|
// filename such as "input_1_X6.png".
|
||||||
|
var pdfcpuListRowID = regexp.MustCompile(`_(X\d+)\.`)
|
||||||
|
|
||||||
|
// pdfcpuImage is one raster image XObject as reported by `pdfcpu images list`.
|
||||||
|
type pdfcpuImage struct {
|
||||||
|
obj int
|
||||||
|
id string
|
||||||
|
masked bool
|
||||||
|
comp int
|
||||||
|
bytes int64
|
||||||
|
filter string
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptimizeImages re-encodes the raster images of inputPath to JPEG in place,
|
||||||
|
// shrinking image-heavy PDFs (a common case for Chromium output, which embeds
|
||||||
|
// non-JPEG images losslessly) while leaving text, vectors, fonts and structure
|
||||||
|
// untouched. See https://github.com/gotenberg/gotenberg/issues/359.
|
||||||
|
//
|
||||||
|
// Only lossless (FlateDecode), non-CMYK, non-masked images at or above
|
||||||
|
// [minOptimizeImageSize] are touched. Already-compressed, transparent, CMYK and
|
||||||
|
// small images are skipped so the pass never enlarges a file or corrupts
|
||||||
|
// transparency. It never fails the conversion for a single unreadable image; it
|
||||||
|
// logs and moves on, and returns the input unchanged when nothing qualifies.
|
||||||
|
func (engine *PdfCpu) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
|
||||||
|
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.OptimizeImages",
|
||||||
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
|
)
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
fail := func(err error) error {
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
images, err := engine.listImages(ctx, inputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fail(fmt.Errorf("optimize PDF images with pdfcpu: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
var targets []pdfcpuImage
|
||||||
|
for _, img := range images {
|
||||||
|
if optimizableImage(img) {
|
||||||
|
targets = append(targets, img)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(targets) == 0 {
|
||||||
|
logger.DebugContext(ctx, "no images to optimize")
|
||||||
|
span.SetStatus(codes.Ok, "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
workDir, err := os.MkdirTemp(filepath.Dir(inputPath), "optimize-images-*")
|
||||||
|
if err != nil {
|
||||||
|
return fail(fmt.Errorf("optimize PDF images with pdfcpu: create work directory: %w", err))
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := os.RemoveAll(workDir); err != nil {
|
||||||
|
logger.ErrorContext(ctx, fmt.Sprintf("remove image optimization work directory: %v", err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
extracted, err := engine.extractImages(ctx, logger, inputPath, workDir)
|
||||||
|
if err != nil {
|
||||||
|
return fail(fmt.Errorf("optimize PDF images with pdfcpu: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chain one update per image. Each update writes a fresh file; current holds
|
||||||
|
// the latest successful output, so a single failed image is skipped without
|
||||||
|
// discarding the ones already done. The input is only replaced on success.
|
||||||
|
current := inputPath
|
||||||
|
optimized := 0
|
||||||
|
for _, img := range targets {
|
||||||
|
src, ok := extracted[img.id]
|
||||||
|
if !ok {
|
||||||
|
logger.WarnContext(ctx, fmt.Sprintf("optimize images: image %s was not extracted, leaving it as is", img.id))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
reencoded := filepath.Join(workDir, img.id+".jpg")
|
||||||
|
err = reencodeToJpeg(src, reencoded, imageQuality)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnContext(ctx, fmt.Sprintf("optimize images: re-encode %s: %v; leaving it as is", img.id, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
next := filepath.Join(workDir, fmt.Sprintf("optimized-%d.pdf", optimized))
|
||||||
|
err = engine.updateImage(ctx, logger, current, reencoded, next, img.obj)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnContext(ctx, fmt.Sprintf("optimize images: update %s: %v; leaving it as is", img.id, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
current = next
|
||||||
|
optimized++
|
||||||
|
}
|
||||||
|
|
||||||
|
if optimized == 0 {
|
||||||
|
span.SetStatus(codes.Ok, "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Rename(current, inputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fail(fmt.Errorf("optimize PDF images with pdfcpu: replace input: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugContext(ctx, fmt.Sprintf("optimized %d image(s) at quality %d", optimized, imageQuality))
|
||||||
|
span.SetStatus(codes.Ok, "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// optimizableImage reports whether an image is a safe, worthwhile target: a
|
||||||
|
// lossless (FlateDecode), non-CMYK, non-masked image at or above the size
|
||||||
|
// threshold. Everything else is left untouched.
|
||||||
|
func optimizableImage(img pdfcpuImage) bool {
|
||||||
|
switch {
|
||||||
|
case img.filter != "FlateDecode":
|
||||||
|
return false // Already compressed (JPEG/JPX); re-encoding would only add loss.
|
||||||
|
case img.comp == 4:
|
||||||
|
return false // CMYK; a JPEG round-trip is unsafe.
|
||||||
|
case img.masked:
|
||||||
|
return false // Soft mask, image mask or alpha; JPEG has no transparency.
|
||||||
|
case img.bytes < minOptimizeImageSize:
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// listImages runs `pdfcpu images list` and parses its table. The command writes
|
||||||
|
// to stdout, so it is run directly to capture the output.
|
||||||
|
func (engine *PdfCpu) listImages(ctx context.Context, inputPath string) ([]pdfcpuImage, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, engine.binPath, "images", "list", inputPath) //nolint:gosec // binPath is validated at Provision; inputPath is a Gotenberg working file.
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("run pdfcpu images list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseImagesList(stdout.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseImagesList parses the fixed-column table of `pdfcpu images list`. Columns
|
||||||
|
// are separated by U+2502; the header and separator rows are skipped because
|
||||||
|
// their second column is not a numeric object number.
|
||||||
|
func parseImagesList(output string) []pdfcpuImage {
|
||||||
|
var images []pdfcpuImage
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
cols := strings.Split(line, "│")
|
||||||
|
if len(cols) < 9 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
obj, err := strconv.Atoi(strings.TrimSpace(cols[1]))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
comp := 0
|
||||||
|
if fields := strings.Fields(cols[6]); len(fields) >= 2 {
|
||||||
|
comp, _ = strconv.Atoi(fields[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
images = append(images, pdfcpuImage{
|
||||||
|
obj: obj,
|
||||||
|
id: strings.TrimSpace(cols[2]),
|
||||||
|
masked: strings.TrimSpace(cols[3]) != "image",
|
||||||
|
comp: comp,
|
||||||
|
bytes: parseHumanSize(cols[7]),
|
||||||
|
filter: strings.TrimSpace(cols[8]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return images
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHumanSize converts a pdfcpu size cell such as "4.4 MB" or "194 KB" into
|
||||||
|
// a byte count.
|
||||||
|
func parseHumanSize(cell string) int64 {
|
||||||
|
fields := strings.Fields(cell)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := strconv.ParseFloat(fields[0], 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
multiplier := float64(1)
|
||||||
|
if len(fields) > 1 {
|
||||||
|
switch strings.ToUpper(fields[1]) {
|
||||||
|
case "KB":
|
||||||
|
multiplier = 1 << 10
|
||||||
|
case "MB":
|
||||||
|
multiplier = 1 << 20
|
||||||
|
case "GB":
|
||||||
|
multiplier = 1 << 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return int64(value * multiplier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractImages extracts every image of inputPath into dir and returns a map of
|
||||||
|
// image Id (e.g. "X6") to the extracted file path.
|
||||||
|
func (engine *PdfCpu) extractImages(ctx context.Context, logger *slog.Logger, inputPath, dir string) (map[string]string, error) {
|
||||||
|
args := []string{"images", "extract", inputPath, dir}
|
||||||
|
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = cmd.Exec()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("extract images: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read extracted images: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
extracted := make(map[string]string, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if match := pdfcpuListRowID.FindStringSubmatch(entry.Name()); match != nil {
|
||||||
|
extracted[match[1]] = filepath.Join(dir, entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return extracted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateImage replaces the image object objNr of inFile with the image at
|
||||||
|
// imagePath, writing the result to outFile. The replacement must share the
|
||||||
|
// original image dimensions, which reencodeToJpeg preserves.
|
||||||
|
func (engine *PdfCpu) updateImage(ctx context.Context, logger *slog.Logger, inFile, imagePath, outFile string, objNr int) error {
|
||||||
|
args := []string{"images", "update", inFile, imagePath, outFile, strconv.Itoa(objNr)}
|
||||||
|
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = cmd.Exec()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reencodeToJpeg decodes the image at src and writes it to dst as JPEG at the
|
||||||
|
// given quality, keeping the original pixel dimensions (pdfcpu requires the
|
||||||
|
// replacement to match). quality is clamped to the valid 1 to 100 range.
|
||||||
|
func reencodeToJpeg(src, dst string, quality int) error {
|
||||||
|
if quality < 1 {
|
||||||
|
quality = 1
|
||||||
|
}
|
||||||
|
if quality > 100 {
|
||||||
|
quality = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
in, err := os.Open(src) //nolint:gosec // src is a file this package extracted into its own temp dir.
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open image: %w", err)
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
|
||||||
|
img, _, err := image.Decode(in)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.Create(dst) //nolint:gosec // dst is a file in this package's own temp dir.
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create re-encoded image: %w", err)
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
err = jpeg.Encode(out, img, &jpeg.Options{Quality: quality})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode JPEG: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
83
pkg/modules/pdfcpu/optimize_test.go
Normal file
83
pkg/modules/pdfcpu/optimize_test.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package pdfcpu
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
const sampleImagesList = `pages: all
|
||||||
|
|
||||||
|
/tmp/multi.pdf:
|
||||||
|
4 images available (8.9 MB)
|
||||||
|
Page │ Obj# │ Id │ Type SoftMask ImgMask │ Width │ Height │ ColorSpace Comp bpc Interp │ Size │ Filters
|
||||||
|
━━━━━┿━━━━━━┿━━━━━┿━━━━━━━━━━━━━━━━━━━━━━━━┿━━━━━━━┿━━━━━━━━┿━━━━━━━━━━━━━━━━━━━━━━━━━━━━┿━━━━━━━━┿━━━━━━━━━━━━
|
||||||
|
1 │ 6 │ X6 │ image │ 2400 │ 1800 │ ICCBased 3 8 │ 5.5 MB │ FlateDecode
|
||||||
|
│ 8 │ X8 │ image * │ 1400 │ 1000 │ ICCBased 3 8 │ 63 KB │ FlateDecode
|
||||||
|
│ 9 │ X9 │ image │ 2400 │ 1800 │ ICCBased 3 8 │ 194 KB │ DCTDecode
|
||||||
|
│ 10 │ X10 │ image │ 120 │ 90 │ DeviceCMYK 4 8 │ 14 KB │ FlateDecode
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestParseImagesList(t *testing.T) {
|
||||||
|
images := parseImagesList(sampleImagesList)
|
||||||
|
|
||||||
|
if len(images) != 4 {
|
||||||
|
t.Fatalf("expected 4 images, got %d", len(images))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
index int
|
||||||
|
obj int
|
||||||
|
id string
|
||||||
|
masked bool
|
||||||
|
comp int
|
||||||
|
filter string
|
||||||
|
}{
|
||||||
|
{0, 6, "X6", false, 3, "FlateDecode"},
|
||||||
|
{1, 8, "X8", true, 3, "FlateDecode"},
|
||||||
|
{2, 9, "X9", false, 3, "DCTDecode"},
|
||||||
|
{3, 10, "X10", false, 4, "FlateDecode"},
|
||||||
|
} {
|
||||||
|
img := images[tc.index]
|
||||||
|
if img.obj != tc.obj || img.id != tc.id || img.masked != tc.masked || img.comp != tc.comp || img.filter != tc.filter {
|
||||||
|
t.Errorf("image %d = %+v, want obj=%d id=%s masked=%v comp=%d filter=%s",
|
||||||
|
tc.index, img, tc.obj, tc.id, tc.masked, tc.comp, tc.filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseHumanSize(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
cell string
|
||||||
|
want int64
|
||||||
|
}{
|
||||||
|
{"5.5 MB", int64(5.5 * (1 << 20))},
|
||||||
|
{"194 KB", 194 << 10},
|
||||||
|
{" 14 KB ", 14 << 10},
|
||||||
|
{"512 B", 512},
|
||||||
|
{"2 GB", 2 << 30},
|
||||||
|
{"", 0},
|
||||||
|
{"garbage", 0},
|
||||||
|
} {
|
||||||
|
if got := parseHumanSize(tc.cell); got != tc.want {
|
||||||
|
t.Errorf("parseHumanSize(%q) = %d, want %d", tc.cell, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOptimizableImage(t *testing.T) {
|
||||||
|
base := pdfcpuImage{obj: 1, id: "X1", masked: false, comp: 3, bytes: 1 << 20, filter: "FlateDecode"}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
mutate func(pdfcpuImage) pdfcpuImage
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"lossless RGB above threshold", func(i pdfcpuImage) pdfcpuImage { return i }, true},
|
||||||
|
{"already compressed", func(i pdfcpuImage) pdfcpuImage { i.filter = "DCTDecode"; return i }, false},
|
||||||
|
{"CMYK", func(i pdfcpuImage) pdfcpuImage { i.comp = 4; return i }, false},
|
||||||
|
{"masked", func(i pdfcpuImage) pdfcpuImage { i.masked = true; return i }, false},
|
||||||
|
{"below threshold", func(i pdfcpuImage) pdfcpuImage { i.bytes = minOptimizeImageSize - 1; return i }, false},
|
||||||
|
{"grayscale above threshold", func(i pdfcpuImage) pdfcpuImage { i.comp = 1; return i }, true},
|
||||||
|
} {
|
||||||
|
if got := optimizableImage(tc.mutate(base)); got != tc.want {
|
||||||
|
t.Errorf("%s: optimizableImage = %v, want %v", tc.scenario, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -308,7 +308,10 @@ func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *slog.Logger, in
|
|||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
tmpPath := fmt.Sprintf("%s.read.json", inputPath)
|
tmpPath := fmt.Sprintf("%s.read.json", inputPath)
|
||||||
args := []string{"bookmarks", "export", inputPath, tmpPath}
|
// --force: without it, a leftover file from an interrupted run makes pdfcpu
|
||||||
|
// refuse, and the stale contents would then be read as this document's
|
||||||
|
// bookmarks.
|
||||||
|
args := []string{"bookmarks", "export", "--force", inputPath, tmpPath}
|
||||||
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = fmt.Errorf("create command: %w", err)
|
err = fmt.Errorf("create command: %w", err)
|
||||||
@@ -456,7 +459,9 @@ func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *slog.Logger, i
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
args := []string{"bookmarks", "import", "--replace", inputPath, tmpPath, inputPath}
|
// --force: the output path is the input path, and pdfcpu refuses to
|
||||||
|
// overwrite an existing file without it.
|
||||||
|
args := []string{"bookmarks", "import", "--replace", "--force", inputPath, tmpPath, inputPath}
|
||||||
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = fmt.Errorf("create command: %w", err)
|
err = fmt.Errorf("create command: %w", err)
|
||||||
@@ -559,8 +564,11 @@ func (engine *PdfCpu) Encrypt(ctx context.Context, logger *slog.Logger, inputPat
|
|||||||
perm = "none"
|
perm = "none"
|
||||||
}
|
}
|
||||||
|
|
||||||
args := make([]string, 0, 11)
|
args := make([]string, 0, 12)
|
||||||
args = append(args, "encrypt")
|
args = append(args, "encrypt")
|
||||||
|
// --force: the output path is the input path, and pdfcpu refuses to
|
||||||
|
// overwrite an existing file without it.
|
||||||
|
args = append(args, "--force")
|
||||||
args = append(args, "--mode", "aes")
|
args = append(args, "--mode", "aes")
|
||||||
args = append(args, "--upw", opts.UserPassword)
|
args = append(args, "--upw", opts.UserPassword)
|
||||||
args = append(args, "--opw", ownerPassword)
|
args = append(args, "--opw", ownerPassword)
|
||||||
@@ -633,7 +641,9 @@ func (engine *PdfCpu) Rotate(ctx context.Context, logger *slog.Logger, inputPath
|
|||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
args := []string{"rotate"}
|
// --force: the output path is the input path, and pdfcpu refuses to
|
||||||
|
// overwrite an existing file without it.
|
||||||
|
args := []string{"rotate", "--force"}
|
||||||
if pages != "" {
|
if pages != "" {
|
||||||
args = append(args, "--pages", pages)
|
args = append(args, "--pages", pages)
|
||||||
}
|
}
|
||||||
@@ -679,7 +689,9 @@ func (engine *PdfCpu) applyStampOrWatermark(ctx context.Context, logger *slog.Lo
|
|||||||
}
|
}
|
||||||
description := strings.Join(descParts, ", ")
|
description := strings.Join(descParts, ", ")
|
||||||
|
|
||||||
args := []string{command, "add", "--mode", mode}
|
// --force: the output path is the input path, and pdfcpu refuses to
|
||||||
|
// overwrite an existing file without it.
|
||||||
|
args := []string{command, "add", "--mode", mode, "--force"}
|
||||||
|
|
||||||
if stamp.Pages != "" {
|
if stamp.Pages != "" {
|
||||||
args = append(args, "--pages", stamp.Pages)
|
args = append(args, "--pages", stamp.Pages)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type multiPdfEngines struct {
|
|||||||
splitEngines []gotenberg.PdfEngine
|
splitEngines []gotenberg.PdfEngine
|
||||||
flattenEngines []gotenberg.PdfEngine
|
flattenEngines []gotenberg.PdfEngine
|
||||||
convertEngines []gotenberg.PdfEngine
|
convertEngines []gotenberg.PdfEngine
|
||||||
|
optimizeImagesEngines []gotenberg.PdfEngine
|
||||||
readMetadataEngines []gotenberg.PdfEngine
|
readMetadataEngines []gotenberg.PdfEngine
|
||||||
writeMetadataEngines []gotenberg.PdfEngine
|
writeMetadataEngines []gotenberg.PdfEngine
|
||||||
passwordEngines []gotenberg.PdfEngine
|
passwordEngines []gotenberg.PdfEngine
|
||||||
@@ -36,6 +37,7 @@ func newMultiPdfEngines(
|
|||||||
splitEngines,
|
splitEngines,
|
||||||
flattenEngines,
|
flattenEngines,
|
||||||
convertEngines,
|
convertEngines,
|
||||||
|
optimizeImagesEngines,
|
||||||
readMetadataEngines,
|
readMetadataEngines,
|
||||||
writeMetadataEngines,
|
writeMetadataEngines,
|
||||||
passwordEngines,
|
passwordEngines,
|
||||||
@@ -53,6 +55,7 @@ func newMultiPdfEngines(
|
|||||||
splitEngines: splitEngines,
|
splitEngines: splitEngines,
|
||||||
flattenEngines: flattenEngines,
|
flattenEngines: flattenEngines,
|
||||||
convertEngines: convertEngines,
|
convertEngines: convertEngines,
|
||||||
|
optimizeImagesEngines: optimizeImagesEngines,
|
||||||
readMetadataEngines: readMetadataEngines,
|
readMetadataEngines: readMetadataEngines,
|
||||||
writeMetadataEngines: writeMetadataEngines,
|
writeMetadataEngines: writeMetadataEngines,
|
||||||
passwordEngines: passwordEngines,
|
passwordEngines: passwordEngines,
|
||||||
@@ -189,6 +192,17 @@ func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *slog.Logger,
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OptimizeImages re-encodes the images of a PDF using the first available
|
||||||
|
// engine that supports image optimization.
|
||||||
|
func (multi *multiPdfEngines) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
|
||||||
|
return runWithFallbackVoid(ctx, "pdfengines.OptimizeImages", multi.optimizeImagesEngines,
|
||||||
|
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||||
|
return engine.OptimizeImages(ctx, logger, imageQuality, inputPath)
|
||||||
|
},
|
||||||
|
func(err error) error { return fmt.Errorf("optimize PDF images with multi PDF engines: %w", err) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Convert transforms the given PDF to a specific PDF format using the first
|
// Convert transforms the given PDF to a specific PDF format using the first
|
||||||
// available engine that supports PDF conversion.
|
// available engine that supports PDF conversion.
|
||||||
func (multi *multiPdfEngines) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
func (multi *multiPdfEngines) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type PdfEngines struct {
|
|||||||
splitNames []string
|
splitNames []string
|
||||||
flattenNames []string
|
flattenNames []string
|
||||||
convertNames []string
|
convertNames []string
|
||||||
|
optimizeImagesNames []string
|
||||||
readMetadataNames []string
|
readMetadataNames []string
|
||||||
writeMetadataNames []string
|
writeMetadataNames []string
|
||||||
encryptNames []string
|
encryptNames []string
|
||||||
@@ -57,6 +58,7 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
fs.StringSlice("pdfengines-split-engines", []string{"pdfcpu", "qpdf", "pdftk"}, "Set the PDF engines and their order for the split feature - empty means all")
|
fs.StringSlice("pdfengines-split-engines", []string{"pdfcpu", "qpdf", "pdftk"}, "Set the PDF engines and their order for the split feature - empty means all")
|
||||||
fs.StringSlice("pdfengines-flatten-engines", []string{"qpdf"}, "Set the PDF engines and their order for the flatten feature - empty means all")
|
fs.StringSlice("pdfengines-flatten-engines", []string{"qpdf"}, "Set the PDF engines and their order for the flatten feature - empty means all")
|
||||||
fs.StringSlice("pdfengines-convert-engines", []string{"libreoffice-pdfengine"}, "Set the PDF engines and their order for the convert feature - empty means all")
|
fs.StringSlice("pdfengines-convert-engines", []string{"libreoffice-pdfengine"}, "Set the PDF engines and their order for the convert feature - empty means all")
|
||||||
|
fs.StringSlice("pdfengines-optimize-images-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the image optimization feature - empty means all")
|
||||||
fs.StringSlice("pdfengines-read-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the read metadata feature - empty means all")
|
fs.StringSlice("pdfengines-read-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the read metadata feature - empty means all")
|
||||||
fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all")
|
fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all")
|
||||||
fs.StringSlice("pdfengines-encrypt-engines", []string{"qpdf", "pdftk", "pdfcpu"}, "Set the PDF engines and their order for the password protection feature - empty means all")
|
fs.StringSlice("pdfengines-encrypt-engines", []string{"qpdf", "pdftk", "pdfcpu"}, "Set the PDF engines and their order for the password protection feature - empty means all")
|
||||||
@@ -91,6 +93,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
|
|||||||
splitNames := flags.MustStringSlice("pdfengines-split-engines")
|
splitNames := flags.MustStringSlice("pdfengines-split-engines")
|
||||||
flattenNames := flags.MustStringSlice("pdfengines-flatten-engines")
|
flattenNames := flags.MustStringSlice("pdfengines-flatten-engines")
|
||||||
convertNames := flags.MustStringSlice("pdfengines-convert-engines")
|
convertNames := flags.MustStringSlice("pdfengines-convert-engines")
|
||||||
|
optimizeImagesNames := flags.MustStringSlice("pdfengines-optimize-images-engines")
|
||||||
readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines")
|
readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines")
|
||||||
writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
|
writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
|
||||||
encryptNames := flags.MustStringSlice("pdfengines-encrypt-engines")
|
encryptNames := flags.MustStringSlice("pdfengines-encrypt-engines")
|
||||||
@@ -148,6 +151,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
|
|||||||
mod.convertNames = convertNames
|
mod.convertNames = convertNames
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod.optimizeImagesNames = defaultNames
|
||||||
|
if len(optimizeImagesNames) > 0 {
|
||||||
|
mod.optimizeImagesNames = optimizeImagesNames
|
||||||
|
}
|
||||||
|
|
||||||
mod.readMetadataNames = defaultNames
|
mod.readMetadataNames = defaultNames
|
||||||
if len(readMetadataNames) > 0 {
|
if len(readMetadataNames) > 0 {
|
||||||
mod.readMetadataNames = readMetadataNames
|
mod.readMetadataNames = readMetadataNames
|
||||||
@@ -247,6 +255,7 @@ func (mod *PdfEngines) Validate() error {
|
|||||||
findNonExistingEngines(mod.mergeNames)
|
findNonExistingEngines(mod.mergeNames)
|
||||||
findNonExistingEngines(mod.splitNames)
|
findNonExistingEngines(mod.splitNames)
|
||||||
findNonExistingEngines(mod.flattenNames)
|
findNonExistingEngines(mod.flattenNames)
|
||||||
|
findNonExistingEngines(mod.optimizeImagesNames)
|
||||||
findNonExistingEngines(mod.convertNames)
|
findNonExistingEngines(mod.convertNames)
|
||||||
findNonExistingEngines(mod.readMetadataNames)
|
findNonExistingEngines(mod.readMetadataNames)
|
||||||
findNonExistingEngines(mod.writeMetadataNames)
|
findNonExistingEngines(mod.writeMetadataNames)
|
||||||
@@ -275,6 +284,7 @@ func (mod *PdfEngines) SystemMessages() []string {
|
|||||||
fmt.Sprintf("split engines - %s", strings.Join(mod.splitNames, " ")),
|
fmt.Sprintf("split engines - %s", strings.Join(mod.splitNames, " ")),
|
||||||
fmt.Sprintf("flatten engines - %s", strings.Join(mod.flattenNames, " ")),
|
fmt.Sprintf("flatten engines - %s", strings.Join(mod.flattenNames, " ")),
|
||||||
fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames, " ")),
|
fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames, " ")),
|
||||||
|
fmt.Sprintf("optimize images engines - %s", strings.Join(mod.optimizeImagesNames, " ")),
|
||||||
fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames, " ")),
|
fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames, " ")),
|
||||||
fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames, " ")),
|
fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames, " ")),
|
||||||
fmt.Sprintf("encrypt engines - %s", strings.Join(mod.encryptNames, " ")),
|
fmt.Sprintf("encrypt engines - %s", strings.Join(mod.encryptNames, " ")),
|
||||||
@@ -310,6 +320,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
|
|||||||
engines(mod.splitNames),
|
engines(mod.splitNames),
|
||||||
engines(mod.flattenNames),
|
engines(mod.flattenNames),
|
||||||
engines(mod.convertNames),
|
engines(mod.convertNames),
|
||||||
|
engines(mod.optimizeImagesNames),
|
||||||
engines(mod.readMetadataNames),
|
engines(mod.readMetadataNames),
|
||||||
engines(mod.writeMetadataNames),
|
engines(mod.writeMetadataNames),
|
||||||
engines(mod.encryptNames),
|
engines(mod.encryptNames),
|
||||||
@@ -341,6 +352,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
|
|||||||
mergeRoute(engine),
|
mergeRoute(engine),
|
||||||
splitRoute(engine),
|
splitRoute(engine),
|
||||||
flattenRoute(engine),
|
flattenRoute(engine),
|
||||||
|
optimizeRoute(engine),
|
||||||
convertRoute(engine),
|
convertRoute(engine),
|
||||||
readMetadataRoute(engine),
|
readMetadataRoute(engine),
|
||||||
writeMetadataRoute(engine),
|
writeMetadataRoute(engine),
|
||||||
|
|||||||
@@ -344,6 +344,65 @@ func FlattenStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPaths []stri
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// defaultImageQuality is the JPEG quality applied by the image optimization
|
||||||
|
// feature when the imageQuality form field is not set.
|
||||||
|
const defaultImageQuality = 80
|
||||||
|
|
||||||
|
// FormDataPdfOptimize extracts the image-optimization options from the form
|
||||||
|
// data: whether to optimize the images, and the JPEG quality (1 to 100) to
|
||||||
|
// apply to each re-encoded image.
|
||||||
|
func FormDataPdfOptimize(form *api.FormData) (bool, int) {
|
||||||
|
var (
|
||||||
|
optimizeImages bool
|
||||||
|
imageQuality int
|
||||||
|
)
|
||||||
|
|
||||||
|
form.
|
||||||
|
Bool("optimizeImages", &optimizeImages, false).
|
||||||
|
Custom("imageQuality", func(value string) error {
|
||||||
|
if value == "" {
|
||||||
|
imageQuality = defaultImageQuality
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
intValue, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if intValue < 1 {
|
||||||
|
return errors.New("value is inferior to 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
if intValue > 100 {
|
||||||
|
return errors.New("value is superior to 100")
|
||||||
|
}
|
||||||
|
|
||||||
|
imageQuality = intValue
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return optimizeImages, imageQuality
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptimizeStub re-encodes the images of each given PDF to shrink the file when
|
||||||
|
// optimizeImages is set, leaving text, vectors and structure untouched. It does
|
||||||
|
// nothing when optimizeImages is false.
|
||||||
|
func OptimizeStub(ctx *api.Context, engine gotenberg.PdfEngine, optimizeImages bool, imageQuality int, inputPaths []string) error {
|
||||||
|
if !optimizeImages {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, inputPath := range inputPaths {
|
||||||
|
err := engine.OptimizeImages(ctx, ctx.Log(), imageQuality, inputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("optimize images of '%s': %w", inputPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ConvertStub transforms a given PDF to the specified formats defined in
|
// ConvertStub transforms a given PDF to the specified formats defined in
|
||||||
// [gotenberg.PdfFormats]. If no format, it does nothing and returns the input
|
// [gotenberg.PdfFormats]. If no format, it does nothing and returns the input
|
||||||
// paths.
|
// paths.
|
||||||
@@ -383,6 +442,27 @@ func WriteMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metadata ma
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// documentTitle returns the input PDF's Title metadata entry, falling back to
|
||||||
|
// the original filename without its extension when the entry is absent, blank,
|
||||||
|
// or cannot be read. It labels the per-document entries the merge route's
|
||||||
|
// titleBookmarks feature generates.
|
||||||
|
func documentTitle(ctx *api.Context, engine gotenberg.PdfEngine, inputPath, filename string) string {
|
||||||
|
fallback := strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||||
|
|
||||||
|
metadata, err := engine.ReadMetadata(ctx, ctx.Log(), inputPath)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Log().WarnContext(ctx, fmt.Sprintf("read metadata of '%s' for title bookmark, using filename: %s", filename, err))
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
title, ok := metadata["Title"].(string)
|
||||||
|
if !ok || strings.TrimSpace(title) == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
|
||||||
func shiftBookmarks(bookmarks []gotenberg.Bookmark, offset int) []gotenberg.Bookmark {
|
func shiftBookmarks(bookmarks []gotenberg.Bookmark, offset int) []gotenberg.Bookmark {
|
||||||
if offset == 0 {
|
if offset == 0 {
|
||||||
return bookmarks
|
return bookmarks
|
||||||
@@ -749,160 +829,149 @@ func EmbedFilesStub(ctx *api.Context, engine gotenberg.PdfEngine, embedPaths []s
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormDataPdfWatermark creates a [gotenberg.Stamp] for watermarking from the
|
// FormDataPdfStamps builds the ordered list of stamps from the repeated stamp
|
||||||
// form data.
|
// fields. See [formDataPdfStampsOrWatermarks].
|
||||||
func FormDataPdfWatermark(form *api.FormData, mandatory bool) gotenberg.Stamp {
|
func FormDataPdfStamps(form *api.FormData) ([]gotenberg.Stamp, error) {
|
||||||
return formDataPdfStampOrWatermark(form, "watermark", mandatory)
|
return formDataPdfStampsOrWatermarks(form, "stamp")
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormDataPdfStamp creates a [gotenberg.Stamp] for stamping from the form data.
|
// FormDataPdfWatermarks builds the ordered list of watermarks from the repeated
|
||||||
func FormDataPdfStamp(form *api.FormData, mandatory bool) gotenberg.Stamp {
|
// watermark fields. See [formDataPdfStampsOrWatermarks].
|
||||||
return formDataPdfStampOrWatermark(form, "stamp", mandatory)
|
func FormDataPdfWatermarks(form *api.FormData) ([]gotenberg.Stamp, error) {
|
||||||
|
return formDataPdfStampsOrWatermarks(form, "watermark")
|
||||||
}
|
}
|
||||||
|
|
||||||
func formDataPdfStampOrWatermark(form *api.FormData, prefix string, mandatory bool) gotenberg.Stamp {
|
// formDataPdfStampsOrWatermarks builds the ordered list of stamps or watermarks
|
||||||
var (
|
// from the repeated {prefix}Source, {prefix}Expression, {prefix}Pages and
|
||||||
source string
|
// {prefix}Options fields. The number of entries equals the number of
|
||||||
expression string
|
// {prefix}Source values, so a single occurrence of each field yields one entry,
|
||||||
pages string
|
// preserving the single-stamp/watermark behavior. Fields are aligned by
|
||||||
options map[string]string
|
// position; a missing expression, pages or options entry defaults to empty.
|
||||||
)
|
// Image and pdf entries take their file from the uploaded files, in order (see
|
||||||
|
// [bindStampOrWatermarkFiles]).
|
||||||
|
func formDataPdfStampsOrWatermarks(form *api.FormData, prefix string) ([]gotenberg.Stamp, error) {
|
||||||
|
var sources, expressions, pages, options []string
|
||||||
|
form.
|
||||||
|
Strings(prefix+"Source", &sources).
|
||||||
|
Strings(prefix+"Expression", &expressions).
|
||||||
|
Strings(prefix+"Pages", &pages).
|
||||||
|
Strings(prefix+"Options", &options)
|
||||||
|
|
||||||
sourceFunc := func(value string) error {
|
at := func(values []string, i int) string {
|
||||||
if value != "" && value != gotenberg.StampSourceText && value != gotenberg.StampSourceImage && value != gotenberg.StampSourcePDF {
|
if i < len(values) {
|
||||||
return fmt.Errorf("wrong value, expected either '%s', '%s' or '%s'", gotenberg.StampSourceText, gotenberg.StampSourceImage, gotenberg.StampSourcePDF)
|
return values[i]
|
||||||
}
|
}
|
||||||
source = value
|
return ""
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
optionsFunc := func(value string) error {
|
stamps := make([]gotenberg.Stamp, 0, len(sources))
|
||||||
if value == "" {
|
for i, source := range sources {
|
||||||
return nil
|
if source != gotenberg.StampSourceText && source != gotenberg.StampSourceImage && source != gotenberg.StampSourcePDF {
|
||||||
|
return nil, api.WrapError(
|
||||||
|
fmt.Errorf("wrong %sSource value '%s'", prefix, source),
|
||||||
|
api.NewSentinelHttpError(
|
||||||
|
http.StatusBadRequest,
|
||||||
|
fmt.Sprintf("Invalid form data: form field '%sSource' is invalid (got '%s', resulting to wrong value, expected either '%s', '%s' or '%s')", prefix, source, gotenberg.StampSourceText, gotenberg.StampSourceImage, gotenberg.StampSourcePDF),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
err := json.Unmarshal([]byte(value), &options)
|
|
||||||
if err != nil {
|
var opts map[string]string
|
||||||
return fmt.Errorf("unmarshal %s options: %w", prefix, err)
|
if raw := at(options, i); raw != "" {
|
||||||
|
err := json.Unmarshal([]byte(raw), &opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, api.WrapError(
|
||||||
|
fmt.Errorf("unmarshal %sOptions: %w", prefix, err),
|
||||||
|
api.NewSentinelHttpError(
|
||||||
|
http.StatusBadRequest,
|
||||||
|
fmt.Sprintf("Invalid form data: form field '%sOptions' is invalid", prefix),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
stamps = append(stamps, gotenberg.Stamp{
|
||||||
|
Source: source,
|
||||||
|
Expression: at(expressions, i),
|
||||||
|
Pages: at(pages, i),
|
||||||
|
Options: opts,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if mandatory {
|
return stamps, nil
|
||||||
form.
|
|
||||||
MandatoryCustom(prefix+"Source", func(value string) error {
|
|
||||||
return sourceFunc(value)
|
|
||||||
}).
|
|
||||||
String(prefix+"Expression", &expression, "").
|
|
||||||
String(prefix+"Pages", &pages, "").
|
|
||||||
Custom(prefix+"Options", func(value string) error {
|
|
||||||
return optionsFunc(value)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
form.
|
|
||||||
Custom(prefix+"Source", func(value string) error {
|
|
||||||
return sourceFunc(value)
|
|
||||||
}).
|
|
||||||
String(prefix+"Expression", &expression, "").
|
|
||||||
String(prefix+"Pages", &pages, "").
|
|
||||||
Custom(prefix+"Options", func(value string) error {
|
|
||||||
return optionsFunc(value)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return gotenberg.Stamp{
|
|
||||||
Source: source,
|
|
||||||
Expression: expression,
|
|
||||||
Pages: pages,
|
|
||||||
Options: options,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormDataPdfWatermarkFile extracts the watermark file path from form data.
|
// BindStampFiles assigns each image or pdf stamp its uploaded stamp file. See
|
||||||
func FormDataPdfWatermarkFile(form *api.FormData) string {
|
// [bindStampOrWatermarkFiles].
|
||||||
var path string
|
func BindStampFiles(stamps []gotenberg.Stamp, stampFiles []string) error {
|
||||||
form.Watermark(&path)
|
return bindStampOrWatermarkFiles(stamps, stampFiles, "stamp")
|
||||||
return path
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormDataPdfStampFile extracts the stamp file path from form data.
|
// BindWatermarkFiles assigns each image or pdf watermark its uploaded watermark
|
||||||
func FormDataPdfStampFile(form *api.FormData) string {
|
// file. See [bindStampOrWatermarkFiles].
|
||||||
var path string
|
func BindWatermarkFiles(watermarks []gotenberg.Stamp, watermarkFiles []string) error {
|
||||||
form.Stamp(&path)
|
return bindStampOrWatermarkFiles(watermarks, watermarkFiles, "watermark")
|
||||||
return path
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureStampFile validates that, when stamp.Source is image or pdf, an
|
// bindStampOrWatermarkFiles assigns each image or pdf entry its uploaded file,
|
||||||
// uploaded stamp file was supplied, and replaces stamp.Expression with
|
// consuming files in order. Text entries take no file. It returns an [api] HTTP
|
||||||
// uploadedFile in that case. Returning an [api] HTTP 400 error prevents
|
// 400 error when an image or pdf entry has no file left to consume, which also
|
||||||
// an anonymous caller from passing an arbitrary filesystem path via
|
// prevents an anonymous caller from passing an arbitrary filesystem path via
|
||||||
// stampExpression and having pdfcpu read it. Source values of text or
|
// the expression field. kind is "stamp" or "watermark" and shapes the error.
|
||||||
// empty are passed through unchanged.
|
func bindStampOrWatermarkFiles(stamps []gotenberg.Stamp, files []string, kind string) error {
|
||||||
func EnsureStampFile(stamp *gotenberg.Stamp, uploadedFile string) error {
|
fileIndex := 0
|
||||||
if stamp.Source != gotenberg.StampSourceImage && stamp.Source != gotenberg.StampSourcePDF {
|
for i := range stamps {
|
||||||
return nil
|
if stamps[i].Source != gotenberg.StampSourceImage && stamps[i].Source != gotenberg.StampSourcePDF {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fileIndex >= len(files) {
|
||||||
|
return api.WrapError(
|
||||||
|
fmt.Errorf("not enough %s files for the image or pdf entries", kind),
|
||||||
|
api.NewSentinelHttpError(
|
||||||
|
http.StatusBadRequest,
|
||||||
|
fmt.Sprintf("Invalid form data: a %s file is required for image or pdf source", kind),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
stamps[i].Expression = files[fileIndex]
|
||||||
|
fileIndex++
|
||||||
}
|
}
|
||||||
if uploadedFile == "" {
|
|
||||||
return api.WrapError(
|
|
||||||
errors.New("no stamp file provided for image or pdf source"),
|
|
||||||
api.NewSentinelHttpError(
|
|
||||||
http.StatusBadRequest,
|
|
||||||
"Invalid form data: a stamp file is required for image or pdf source",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
stamp.Expression = uploadedFile
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureWatermarkFile mirrors [EnsureStampFile] for a watermark. The
|
// WatermarkStub applies each watermark to a list of PDF files, in order.
|
||||||
// shape is identical: image or pdf sources must be accompanied by an
|
// Entries with no source are skipped, so an empty list does nothing.
|
||||||
// uploaded file, and the file path replaces watermark.Expression to
|
func WatermarkStub(ctx *api.Context, engine gotenberg.PdfEngine, watermarks []gotenberg.Stamp, inputPaths []string) error {
|
||||||
// prevent pdfcpu from reading an attacker-controlled path.
|
for _, watermark := range watermarks {
|
||||||
func EnsureWatermarkFile(watermark *gotenberg.Stamp, uploadedFile string) error {
|
if watermark.Source == "" {
|
||||||
if watermark.Source != gotenberg.StampSourceImage && watermark.Source != gotenberg.StampSourcePDF {
|
continue
|
||||||
return nil
|
}
|
||||||
}
|
|
||||||
if uploadedFile == "" {
|
|
||||||
return api.WrapError(
|
|
||||||
errors.New("no watermark file provided for image or pdf source"),
|
|
||||||
api.NewSentinelHttpError(
|
|
||||||
http.StatusBadRequest,
|
|
||||||
"Invalid form data: a watermark file is required for image or pdf source",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
watermark.Expression = uploadedFile
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// WatermarkStub applies a watermark to a list of PDF files. If the stamp has
|
for _, inputPath := range inputPaths {
|
||||||
// no source, it does nothing.
|
err := engine.Watermark(ctx, ctx.Log(), inputPath, watermark)
|
||||||
func WatermarkStub(ctx *api.Context, engine gotenberg.PdfEngine, stamp gotenberg.Stamp, inputPaths []string) error {
|
if err != nil {
|
||||||
if stamp.Source == "" {
|
return fmt.Errorf("watermark '%s': %w", inputPath, err)
|
||||||
return nil
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for _, inputPath := range inputPaths {
|
|
||||||
err := engine.Watermark(ctx, ctx.Log(), inputPath, stamp)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("watermark '%s': %w", inputPath, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StampStub applies a stamp to a list of PDF files. If the stamp has
|
// StampStub applies each stamp to a list of PDF files, in order. Entries with
|
||||||
// no source, it does nothing.
|
// no source are skipped, so an empty list does nothing.
|
||||||
func StampStub(ctx *api.Context, engine gotenberg.PdfEngine, stamp gotenberg.Stamp, inputPaths []string) error {
|
func StampStub(ctx *api.Context, engine gotenberg.PdfEngine, stamps []gotenberg.Stamp, inputPaths []string) error {
|
||||||
if stamp.Source == "" {
|
for _, stamp := range stamps {
|
||||||
return nil
|
if stamp.Source == "" {
|
||||||
}
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
for _, inputPath := range inputPaths {
|
for _, inputPath := range inputPaths {
|
||||||
err := engine.Stamp(ctx, ctx.Log(), inputPath, stamp)
|
err := engine.Stamp(ctx, ctx.Log(), inputPath, stamp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("stamp '%s': %w", inputPath, err)
|
return fmt.Errorf("stamp '%s': %w", inputPath, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -924,33 +993,42 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
bookmarks := FormDataPdfBookmarks(form, false)
|
bookmarks := FormDataPdfBookmarks(form, false)
|
||||||
encrypt := FormDataPdfEncrypt(form)
|
encrypt := FormDataPdfEncrypt(form)
|
||||||
embedPaths := FormDataPdfEmbeds(form)
|
embedPaths := FormDataPdfEmbeds(form)
|
||||||
watermark := FormDataPdfWatermark(form, false)
|
watermarks, wErr := FormDataPdfWatermarks(form)
|
||||||
watermarkFile := FormDataPdfWatermarkFile(form)
|
if wErr != nil {
|
||||||
stamp := FormDataPdfStamp(form, false)
|
return fmt.Errorf("form data watermarks: %w", wErr)
|
||||||
stampFile := FormDataPdfStampFile(form)
|
}
|
||||||
|
stamps, sErr := FormDataPdfStamps(form)
|
||||||
|
if sErr != nil {
|
||||||
|
return fmt.Errorf("form data stamps: %w", sErr)
|
||||||
|
}
|
||||||
|
var watermarkFiles, stampFiles []string
|
||||||
|
form.Watermarks(&watermarkFiles).Stamps(&stampFiles)
|
||||||
angle, rotatePages := FormDataPdfRotate(form, false)
|
angle, rotatePages := FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
||||||
facturX, facturxXmlPath := FormDataPdfFacturX(form)
|
facturX, facturxXmlPath := FormDataPdfFacturX(form)
|
||||||
|
optimizeImages, imageQuality := FormDataPdfOptimize(form)
|
||||||
|
|
||||||
var inputPaths []string
|
var inputPaths []string
|
||||||
var flatten bool
|
var flatten bool
|
||||||
var autoIndexBookmarks bool
|
var autoIndexBookmarks bool
|
||||||
|
var titleBookmarks bool
|
||||||
err := form.
|
err := form.
|
||||||
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
||||||
Bool("flatten", &flatten, false).
|
Bool("flatten", &flatten, false).
|
||||||
Bool("autoIndexBookmarks", &autoIndexBookmarks, false).
|
Bool("autoIndexBookmarks", &autoIndexBookmarks, false).
|
||||||
|
Bool("titleBookmarks", &titleBookmarks, false).
|
||||||
Validate()
|
Validate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate form data: %w", err)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = EnsureWatermarkFile(&watermark, watermarkFile)
|
err = BindWatermarkFiles(watermarks, watermarkFiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate watermark: %w", err)
|
return fmt.Errorf("bind watermark files: %w", err)
|
||||||
}
|
}
|
||||||
err = EnsureStampFile(&stamp, stampFile)
|
err = BindStampFiles(stamps, stampFiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate stamp: %w", err)
|
return fmt.Errorf("bind stamp files: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
|
err = ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
|
||||||
@@ -976,12 +1054,12 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
|
|
||||||
outputPaths := []string{outputPath}
|
outputPaths := []string{outputPath}
|
||||||
|
|
||||||
err = WatermarkStub(ctx, engine, watermark, outputPaths)
|
err = WatermarkStub(ctx, engine, watermarks, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("watermark PDFs: %w", err)
|
return fmt.Errorf("watermark PDFs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = StampStub(ctx, engine, stamp, outputPaths)
|
err = StampStub(ctx, engine, stamps, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("stamp PDFs: %w", err)
|
return fmt.Errorf("stamp PDFs: %w", err)
|
||||||
}
|
}
|
||||||
@@ -998,6 +1076,11 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = OptimizeStub(ctx, engine, optimizeImages, imageQuality, outputPaths)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("optimize PDF images: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
pdfFormats = FacturXPdfFormats(ctx, engine, facturX, pdfFormats, false, outputPaths)
|
pdfFormats = FacturXPdfFormats(ctx, engine, facturX, pdfFormats, false, outputPaths)
|
||||||
|
|
||||||
outputPaths, err = ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
outputPaths, err = ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
||||||
@@ -1013,7 +1096,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
finalBookmarks = b
|
finalBookmarks = b
|
||||||
} else {
|
} else {
|
||||||
bMap, _ := bookmarks.(map[string][]gotenberg.Bookmark)
|
bMap, _ := bookmarks.(map[string][]gotenberg.Bookmark)
|
||||||
if bMap != nil || autoIndexBookmarks {
|
if bMap != nil || autoIndexBookmarks || titleBookmarks {
|
||||||
offset := 0
|
offset := 0
|
||||||
for _, inputPath := range inputPaths {
|
for _, inputPath := range inputPaths {
|
||||||
filename := ctx.OriginalFilename(inputPath)
|
filename := ctx.OriginalFilename(inputPath)
|
||||||
@@ -1023,7 +1106,9 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
fileBookmarks = bMap[filename]
|
fileBookmarks = bMap[filename]
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(fileBookmarks) == 0 && autoIndexBookmarks {
|
// titleBookmarks nests each input's own outline under
|
||||||
|
// its title entry, so its outline is read here too.
|
||||||
|
if len(fileBookmarks) == 0 && (autoIndexBookmarks || titleBookmarks) {
|
||||||
fb, err := engine.ReadBookmarks(ctx, ctx.Log(), inputPath)
|
fb, err := engine.ReadBookmarks(ctx, ctx.Log(), inputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read bookmarks of '%s': %w", filename, err)
|
return fmt.Errorf("read bookmarks of '%s': %w", filename, err)
|
||||||
@@ -1031,8 +1116,16 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
fileBookmarks = fb
|
fileBookmarks = fb
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(fileBookmarks) > 0 {
|
fileBookmarks = shiftBookmarks(fileBookmarks, offset)
|
||||||
finalBookmarks = append(finalBookmarks, shiftBookmarks(fileBookmarks, offset)...)
|
|
||||||
|
if titleBookmarks {
|
||||||
|
finalBookmarks = append(finalBookmarks, gotenberg.Bookmark{
|
||||||
|
Title: documentTitle(ctx, engine, inputPath, filename),
|
||||||
|
Page: offset + 1,
|
||||||
|
Children: fileBookmarks,
|
||||||
|
})
|
||||||
|
} else if len(fileBookmarks) > 0 {
|
||||||
|
finalBookmarks = append(finalBookmarks, fileBookmarks...)
|
||||||
}
|
}
|
||||||
|
|
||||||
pageCount, err := engine.PageCount(ctx, ctx.Log(), inputPath)
|
pageCount, err := engine.PageCount(ctx, ctx.Log(), inputPath)
|
||||||
@@ -1101,13 +1194,20 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
metadata := FormDataPdfMetadata(form, false)
|
metadata := FormDataPdfMetadata(form, false)
|
||||||
encrypt := FormDataPdfEncrypt(form)
|
encrypt := FormDataPdfEncrypt(form)
|
||||||
embedPaths := FormDataPdfEmbeds(form)
|
embedPaths := FormDataPdfEmbeds(form)
|
||||||
watermark := FormDataPdfWatermark(form, false)
|
watermarks, wErr := FormDataPdfWatermarks(form)
|
||||||
watermarkFile := FormDataPdfWatermarkFile(form)
|
if wErr != nil {
|
||||||
stamp := FormDataPdfStamp(form, false)
|
return fmt.Errorf("form data watermarks: %w", wErr)
|
||||||
stampFile := FormDataPdfStampFile(form)
|
}
|
||||||
|
stamps, sErr := FormDataPdfStamps(form)
|
||||||
|
if sErr != nil {
|
||||||
|
return fmt.Errorf("form data stamps: %w", sErr)
|
||||||
|
}
|
||||||
|
var watermarkFiles, stampFiles []string
|
||||||
|
form.Watermarks(&watermarkFiles).Stamps(&stampFiles)
|
||||||
angle, rotatePages := FormDataPdfRotate(form, false)
|
angle, rotatePages := FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
||||||
facturX, facturxXmlPath := FormDataPdfFacturX(form)
|
facturX, facturxXmlPath := FormDataPdfFacturX(form)
|
||||||
|
optimizeImages, imageQuality := FormDataPdfOptimize(form)
|
||||||
|
|
||||||
var inputPaths []string
|
var inputPaths []string
|
||||||
var flatten bool
|
var flatten bool
|
||||||
@@ -1119,13 +1219,13 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("validate form data: %w", err)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = EnsureWatermarkFile(&watermark, watermarkFile)
|
err = BindWatermarkFiles(watermarks, watermarkFiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate watermark: %w", err)
|
return fmt.Errorf("bind watermark files: %w", err)
|
||||||
}
|
}
|
||||||
err = EnsureStampFile(&stamp, stampFile)
|
err = BindStampFiles(stamps, stampFiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate stamp: %w", err)
|
return fmt.Errorf("bind stamp files: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
|
err = ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
|
||||||
@@ -1148,12 +1248,12 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("split PDFs: %w", err)
|
return fmt.Errorf("split PDFs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = WatermarkStub(ctx, engine, watermark, outputPaths)
|
err = WatermarkStub(ctx, engine, watermarks, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("watermark PDFs: %w", err)
|
return fmt.Errorf("watermark PDFs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = StampStub(ctx, engine, stamp, outputPaths)
|
err = StampStub(ctx, engine, stamps, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("stamp PDFs: %w", err)
|
return fmt.Errorf("stamp PDFs: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1170,6 +1270,11 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = OptimizeStub(ctx, engine, optimizeImages, imageQuality, outputPaths)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("optimize PDF images: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
pdfFormats = FacturXPdfFormats(ctx, engine, facturX, pdfFormats, false, outputPaths)
|
pdfFormats = FacturXPdfFormats(ctx, engine, facturX, pdfFormats, false, outputPaths)
|
||||||
|
|
||||||
convertOutputPaths, err := ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
convertOutputPaths, err := ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
||||||
@@ -1262,6 +1367,42 @@ func flattenRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
|
|
||||||
// convertRoute returns an [api.Route] which can convert PDFs to a specific ODF
|
// convertRoute returns an [api.Route] which can convert PDFs to a specific ODF
|
||||||
// format.
|
// format.
|
||||||
|
func optimizeRoute(engine gotenberg.PdfEngine) api.Route {
|
||||||
|
return api.Route{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/forms/pdfengines/optimize",
|
||||||
|
IsMultipart: true,
|
||||||
|
Handler: func(c echo.Context) error {
|
||||||
|
ctx := c.Get("context").(*api.Context)
|
||||||
|
|
||||||
|
form := ctx.FormData()
|
||||||
|
// This route optimizes unconditionally, so the optimizeImages toggle
|
||||||
|
// is ignored; only the image quality is read.
|
||||||
|
_, imageQuality := FormDataPdfOptimize(form)
|
||||||
|
|
||||||
|
var inputPaths []string
|
||||||
|
err := form.
|
||||||
|
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
||||||
|
Validate()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = OptimizeStub(ctx, engine, true, imageQuality, inputPaths)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("optimize PDF images: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ctx.AddOutputPaths(inputPaths...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("add output paths: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func convertRoute(engine gotenberg.PdfEngine) api.Route {
|
func convertRoute(engine gotenberg.PdfEngine) api.Route {
|
||||||
return api.Route{
|
return api.Route{
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
@@ -1590,23 +1731,37 @@ func watermarkRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
ctx := c.Get("context").(*api.Context)
|
ctx := c.Get("context").(*api.Context)
|
||||||
|
|
||||||
form := ctx.FormData()
|
form := ctx.FormData()
|
||||||
stamp := FormDataPdfWatermark(form, true)
|
watermarks, err := FormDataPdfWatermarks(form)
|
||||||
watermarkFile := FormDataPdfWatermarkFile(form)
|
if err != nil {
|
||||||
|
return fmt.Errorf("form data watermarks: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
var inputPaths []string
|
var inputPaths []string
|
||||||
err := form.
|
var watermarkFiles []string
|
||||||
|
err = form.
|
||||||
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
||||||
|
Watermarks(&watermarkFiles).
|
||||||
Validate()
|
Validate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate form data: %w", err)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = EnsureWatermarkFile(&stamp, watermarkFile)
|
if len(watermarks) == 0 {
|
||||||
if err != nil {
|
return api.WrapError(
|
||||||
return fmt.Errorf("validate watermark: %w", err)
|
errors.New("no watermark provided"),
|
||||||
|
api.NewSentinelHttpError(
|
||||||
|
http.StatusBadRequest,
|
||||||
|
"Invalid form data: form field 'watermarkSource' is required",
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = WatermarkStub(ctx, engine, stamp, inputPaths)
|
err = BindWatermarkFiles(watermarks, watermarkFiles)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("bind watermark files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = WatermarkStub(ctx, engine, watermarks, inputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("watermark PDFs: %w", err)
|
return fmt.Errorf("watermark PDFs: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1633,23 +1788,41 @@ func stampRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
ctx := c.Get("context").(*api.Context)
|
ctx := c.Get("context").(*api.Context)
|
||||||
|
|
||||||
form := ctx.FormData()
|
form := ctx.FormData()
|
||||||
stamp := FormDataPdfStamp(form, true)
|
|
||||||
stampFile := FormDataPdfStampFile(form)
|
// Reading the stamp fields as parallel arrays applies several
|
||||||
|
// stamps in one request. A single occurrence of each field is the
|
||||||
|
// existing single-stamp behavior.
|
||||||
|
stamps, err := FormDataPdfStamps(form)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("form data stamps: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
var inputPaths []string
|
var inputPaths []string
|
||||||
err := form.
|
var stampFiles []string
|
||||||
|
err = form.
|
||||||
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
||||||
|
Stamps(&stampFiles).
|
||||||
Validate()
|
Validate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate form data: %w", err)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = EnsureStampFile(&stamp, stampFile)
|
if len(stamps) == 0 {
|
||||||
if err != nil {
|
return api.WrapError(
|
||||||
return fmt.Errorf("validate stamp: %w", err)
|
errors.New("no stamp provided"),
|
||||||
|
api.NewSentinelHttpError(
|
||||||
|
http.StatusBadRequest,
|
||||||
|
"Invalid form data: form field 'stampSource' is required",
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = StampStub(ctx, engine, stamp, inputPaths)
|
err = BindStampFiles(stamps, stampFiles)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("bind stamp files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = StampStub(ctx, engine, stamps, inputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("stamp PDFs: %w", err)
|
return fmt.Errorf("stamp PDFs: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
181
pkg/modules/pdfengines/stamp_test.go
Normal file
181
pkg/modules/pdfengines/stamp_test.go
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
package pdfengines
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormDataPdfStamps(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
values map[string][]string
|
||||||
|
expect []gotenberg.Stamp
|
||||||
|
expectErr bool
|
||||||
|
expectCode int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "single text stamp (backward compatible)",
|
||||||
|
values: map[string][]string{
|
||||||
|
"stampSource": {"text"},
|
||||||
|
"stampExpression": {"CONFIDENTIAL"},
|
||||||
|
"stampOptions": {`{"rot":"45"}`},
|
||||||
|
},
|
||||||
|
expect: []gotenberg.Stamp{
|
||||||
|
{Source: "text", Expression: "CONFIDENTIAL", Options: map[string]string{"rot": "45"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "multiple stamps aligned by position",
|
||||||
|
values: map[string][]string{
|
||||||
|
"stampSource": {"text", "image"},
|
||||||
|
"stampExpression": {"ONE"},
|
||||||
|
"stampPages": {"1-2", "3"},
|
||||||
|
"stampOptions": {`{"pos":"tl"}`, `{"pos":"br"}`},
|
||||||
|
},
|
||||||
|
expect: []gotenberg.Stamp{
|
||||||
|
{Source: "text", Expression: "ONE", Pages: "1-2", Options: map[string]string{"pos": "tl"}},
|
||||||
|
{Source: "image", Expression: "", Pages: "3", Options: map[string]string{"pos": "br"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "no stamp fields",
|
||||||
|
values: map[string][]string{},
|
||||||
|
expect: []gotenberg.Stamp{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "invalid source",
|
||||||
|
values: map[string][]string{"stampSource": {"text", "foo"}},
|
||||||
|
expectErr: true,
|
||||||
|
expectCode: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "invalid options JSON",
|
||||||
|
values: map[string][]string{
|
||||||
|
"stampSource": {"text"},
|
||||||
|
"stampOptions": {"{"},
|
||||||
|
},
|
||||||
|
expectErr: true,
|
||||||
|
expectCode: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
ctx := &api.ContextMock{Context: &api.Context{}}
|
||||||
|
ctx.SetValues(tc.values)
|
||||||
|
form := ctx.FormData()
|
||||||
|
|
||||||
|
got, err := FormDataPdfStamps(form)
|
||||||
|
|
||||||
|
if tc.expectErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error, got nil")
|
||||||
|
}
|
||||||
|
var httpErr api.HttpError
|
||||||
|
if !errors.As(err, &httpErr) {
|
||||||
|
t.Fatalf("expected an api.HttpError, got %T", err)
|
||||||
|
}
|
||||||
|
if status, _ := httpErr.HttpError(); status != tc.expectCode {
|
||||||
|
t.Fatalf("status = %d, want %d", status, tc.expectCode)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, tc.expect) {
|
||||||
|
t.Fatalf("stamps = %#v, want %#v", got, tc.expect)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBindStampFiles(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
stamps []gotenberg.Stamp
|
||||||
|
files []string
|
||||||
|
expect []gotenberg.Stamp
|
||||||
|
expectErr bool
|
||||||
|
expectCode int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "text stamps consume no files",
|
||||||
|
stamps: []gotenberg.Stamp{{Source: "text", Expression: "FOO"}},
|
||||||
|
expect: []gotenberg.Stamp{{Source: "text", Expression: "FOO"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "image and pdf stamps consume files in order, overwriting expression",
|
||||||
|
stamps: []gotenberg.Stamp{
|
||||||
|
{Source: "image", Expression: "ignored"},
|
||||||
|
{Source: "text", Expression: "MIDDLE"},
|
||||||
|
{Source: "pdf"},
|
||||||
|
},
|
||||||
|
files: []string{"/a.png", "/b.pdf"},
|
||||||
|
expect: []gotenberg.Stamp{
|
||||||
|
{Source: "image", Expression: "/a.png"},
|
||||||
|
{Source: "text", Expression: "MIDDLE"},
|
||||||
|
{Source: "pdf", Expression: "/b.pdf"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "not enough files for the image or pdf stamps",
|
||||||
|
stamps: []gotenberg.Stamp{{Source: "image"}, {Source: "image"}},
|
||||||
|
files: []string{"/a.png"},
|
||||||
|
expectErr: true,
|
||||||
|
expectCode: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
err := BindStampFiles(tc.stamps, tc.files)
|
||||||
|
|
||||||
|
if tc.expectErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error, got nil")
|
||||||
|
}
|
||||||
|
var httpErr api.HttpError
|
||||||
|
if !errors.As(err, &httpErr) {
|
||||||
|
t.Fatalf("expected an api.HttpError, got %T", err)
|
||||||
|
}
|
||||||
|
if status, _ := httpErr.HttpError(); status != tc.expectCode {
|
||||||
|
t.Fatalf("status = %d, want %d", status, tc.expectCode)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(tc.stamps, tc.expect) {
|
||||||
|
t.Fatalf("stamps = %#v, want %#v", tc.stamps, tc.expect)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormDataPdfWatermarks(t *testing.T) {
|
||||||
|
ctx := &api.ContextMock{Context: &api.Context{}}
|
||||||
|
ctx.SetValues(map[string][]string{
|
||||||
|
"watermarkSource": {"text", "image"},
|
||||||
|
"watermarkExpression": {"DRAFT"},
|
||||||
|
"watermarkOptions": {`{"opacity":"0.5"}`, ""},
|
||||||
|
})
|
||||||
|
form := ctx.FormData()
|
||||||
|
|
||||||
|
got, err := FormDataPdfWatermarks(form)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []gotenberg.Stamp{
|
||||||
|
{Source: "text", Expression: "DRAFT", Options: map[string]string{"opacity": "0.5"}},
|
||||||
|
{Source: "image"},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("watermarks = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -219,6 +219,20 @@ func (engine *PdfTk) Convert(ctx context.Context, logger *slog.Logger, formats g
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OptimizeImages is not available in this implementation.
|
||||||
|
func (engine *PdfTk) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
|
||||||
|
_, span := gotenberg.Tracer().Start(ctx, "pdftk.OptimizeImages",
|
||||||
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
|
)
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
err := fmt.Errorf("optimize PDF images with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// ReadMetadata is not available in this implementation.
|
// ReadMetadata is not available in this implementation.
|
||||||
func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.ReadMetadata",
|
_, span := gotenberg.Tracer().Start(ctx, "pdftk.ReadMetadata",
|
||||||
|
|||||||
@@ -255,6 +255,20 @@ func (engine *QPdf) Convert(ctx context.Context, logger *slog.Logger, formats go
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OptimizeImages is not available in this implementation.
|
||||||
|
func (engine *QPdf) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
|
||||||
|
_, span := gotenberg.Tracer().Start(ctx, "qpdf.OptimizeImages",
|
||||||
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
|
trace.WithAttributes(engine.spanAttrs()...),
|
||||||
|
)
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
err := fmt.Errorf("optimize PDF images with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// ReadMetadata is not available in this implementation.
|
// ReadMetadata is not available in this implementation.
|
||||||
func (engine *QPdf) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
func (engine *QPdf) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadMetadata",
|
_, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadMetadata",
|
||||||
@@ -454,7 +468,7 @@ func (engine *QPdf) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
catalogRef, catalogValue, filespecRefs, updateObjects := patchFilespecMetadata(logger, objects, metadata)
|
catalogRef, catalogValue, filespecRefs, updateObjects := patchFilespecMetadata(ctx, logger, objects, metadata)
|
||||||
if len(filespecRefs) == 0 {
|
if len(filespecRefs) == 0 {
|
||||||
span.SetStatus(codes.Ok, "")
|
span.SetStatus(codes.Ok, "")
|
||||||
return nil
|
return nil
|
||||||
@@ -506,7 +520,7 @@ func parsePdfObjects(output []byte) (map[string]json.RawMessage, error) {
|
|||||||
// metadata keys. It sets /AFRelationship and /Subtype on matching objects
|
// metadata keys. It sets /AFRelationship and /Subtype on matching objects
|
||||||
// and returns the catalog reference, catalog value, filespec references,
|
// and returns the catalog reference, catalog value, filespec references,
|
||||||
// and the update objects map.
|
// and the update objects map.
|
||||||
func patchFilespecMetadata(logger *slog.Logger, objects map[string]json.RawMessage, metadata map[string]map[string]string) (string, map[string]any, []string, map[string]any) {
|
func patchFilespecMetadata(ctx context.Context, logger *slog.Logger, objects map[string]json.RawMessage, metadata map[string]map[string]string) (string, map[string]any, []string, map[string]any) {
|
||||||
updateObjects := make(map[string]any)
|
updateObjects := make(map[string]any)
|
||||||
var catalogRef string
|
var catalogRef string
|
||||||
var catalogValue map[string]any
|
var catalogValue map[string]any
|
||||||
@@ -556,7 +570,7 @@ func patchFilespecMetadata(logger *slog.Logger, objects map[string]json.RawMessa
|
|||||||
if ef, ok := value["/EF"].(map[string]any); ok {
|
if ef, ok := value["/EF"].(map[string]any); ok {
|
||||||
efRef, _ := ef["/F"].(string)
|
efRef, _ := ef["/F"].(string)
|
||||||
if efRef != "" {
|
if efRef != "" {
|
||||||
setStreamSubtype(logger, objects, updateObjects, efRef, mimeType)
|
setStreamSubtype(ctx, logger, objects, updateObjects, efRef, mimeType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -653,38 +667,38 @@ func (engine *QPdf) writeAndApplyUpdate(ctx context.Context, logger *slog.Logger
|
|||||||
|
|
||||||
// setStreamSubtype finds a stream object by reference and sets the /Subtype
|
// setStreamSubtype finds a stream object by reference and sets the /Subtype
|
||||||
// key in its dict.
|
// key in its dict.
|
||||||
func setStreamSubtype(logger *slog.Logger, objects map[string]json.RawMessage, updateObjects map[string]any, ref, mimeType string) {
|
func setStreamSubtype(ctx context.Context, logger *slog.Logger, objects map[string]json.RawMessage, updateObjects map[string]any, ref, mimeType string) {
|
||||||
objKey := ref
|
objKey := ref
|
||||||
if !strings.HasPrefix(objKey, "obj:") {
|
if !strings.HasPrefix(objKey, "obj:") {
|
||||||
objKey = "obj:" + objKey
|
objKey = "obj:" + objKey
|
||||||
}
|
}
|
||||||
raw, ok := objects[objKey]
|
raw, ok := objects[objKey]
|
||||||
if !ok {
|
if !ok {
|
||||||
logger.Warn(fmt.Sprintf("set stream subtype on %s: object not found", ref))
|
logger.WarnContext(ctx, fmt.Sprintf("set stream subtype on %s: object not found", ref))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var obj map[string]json.RawMessage
|
var obj map[string]json.RawMessage
|
||||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||||
logger.Warn(fmt.Sprintf("set stream subtype on %s: unmarshal object: %s", ref, err))
|
logger.WarnContext(ctx, fmt.Sprintf("set stream subtype on %s: unmarshal object: %s", ref, err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
streamRaw, ok := obj["stream"]
|
streamRaw, ok := obj["stream"]
|
||||||
if !ok {
|
if !ok {
|
||||||
logger.Warn(fmt.Sprintf("set stream subtype on %s: no stream key", ref))
|
logger.WarnContext(ctx, fmt.Sprintf("set stream subtype on %s: no stream key", ref))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var stream map[string]any
|
var stream map[string]any
|
||||||
if err := json.Unmarshal(streamRaw, &stream); err != nil {
|
if err := json.Unmarshal(streamRaw, &stream); err != nil {
|
||||||
logger.Warn(fmt.Sprintf("set stream subtype on %s: unmarshal stream: %s", ref, err))
|
logger.WarnContext(ctx, fmt.Sprintf("set stream subtype on %s: unmarshal stream: %s", ref, err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
dict, ok := stream["dict"].(map[string]any)
|
dict, ok := stream["dict"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
logger.Warn(fmt.Sprintf("set stream subtype on %s: stream dict is not a map", ref))
|
logger.WarnContext(ctx, fmt.Sprintf("set stream subtype on %s: stream dict is not a map", ref))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package qpdf
|
package qpdf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -99,7 +100,7 @@ func TestPatchFilespecMetadata(t *testing.T) {
|
|||||||
"factur-x.xml": {"relationship": "Data"},
|
"factur-x.xml": {"relationship": "Data"},
|
||||||
}
|
}
|
||||||
|
|
||||||
catalogRef, _, filespecRefs, updateObjects := patchFilespecMetadata(logger, objects, metadata)
|
catalogRef, _, filespecRefs, updateObjects := patchFilespecMetadata(context.Background(), logger, objects, metadata)
|
||||||
|
|
||||||
if catalogRef != "obj:1 0 R" {
|
if catalogRef != "obj:1 0 R" {
|
||||||
t.Errorf("catalogRef = %q, want %q", catalogRef, "obj:1 0 R")
|
t.Errorf("catalogRef = %q, want %q", catalogRef, "obj:1 0 R")
|
||||||
@@ -125,7 +126,7 @@ func TestPatchFilespecMetadata(t *testing.T) {
|
|||||||
"factur-x.xml": {"relationship": "Data"},
|
"factur-x.xml": {"relationship": "Data"},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, filespecRefs, _ := patchFilespecMetadata(logger, objects, metadata)
|
_, _, filespecRefs, _ := patchFilespecMetadata(context.Background(), logger, objects, metadata)
|
||||||
if len(filespecRefs) != 0 {
|
if len(filespecRefs) != 0 {
|
||||||
t.Errorf("filespecRefs = %v, want empty", filespecRefs)
|
t.Errorf("filespecRefs = %v, want empty", filespecRefs)
|
||||||
}
|
}
|
||||||
@@ -139,7 +140,7 @@ func TestPatchFilespecMetadata(t *testing.T) {
|
|||||||
"factur-x.xml": {"relationship": "Alternative"},
|
"factur-x.xml": {"relationship": "Alternative"},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, filespecRefs, updateObjects := patchFilespecMetadata(logger, objects, metadata)
|
_, _, filespecRefs, updateObjects := patchFilespecMetadata(context.Background(), logger, objects, metadata)
|
||||||
if len(filespecRefs) != 1 {
|
if len(filespecRefs) != 1 {
|
||||||
t.Fatalf("filespecRefs = %v, want 1 entry", filespecRefs)
|
t.Fatalf("filespecRefs = %v, want 1 entry", filespecRefs)
|
||||||
}
|
}
|
||||||
@@ -158,7 +159,7 @@ func TestPatchFilespecMetadata(t *testing.T) {
|
|||||||
"factur-x.xml": {"mimeType": "text/xml"},
|
"factur-x.xml": {"mimeType": "text/xml"},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, _, updateObjects := patchFilespecMetadata(logger, objects, metadata)
|
_, _, _, updateObjects := patchFilespecMetadata(context.Background(), logger, objects, metadata)
|
||||||
streamObj, ok := updateObjects["obj:3 0 R"]
|
streamObj, ok := updateObjects["obj:3 0 R"]
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected obj:3 0 R in updateObjects")
|
t.Fatal("expected obj:3 0 R in updateObjects")
|
||||||
@@ -223,7 +224,7 @@ func TestSetStreamSubtype(t *testing.T) {
|
|||||||
}
|
}
|
||||||
updateObjects := make(map[string]any)
|
updateObjects := make(map[string]any)
|
||||||
|
|
||||||
setStreamSubtype(logger, objects, updateObjects, "obj:3 0 R", "text/xml")
|
setStreamSubtype(context.Background(), logger, objects, updateObjects, "obj:3 0 R", "text/xml")
|
||||||
|
|
||||||
streamObj := updateObjects["obj:3 0 R"].(map[string]any)["stream"].(map[string]any)
|
streamObj := updateObjects["obj:3 0 R"].(map[string]any)["stream"].(map[string]any)
|
||||||
dict := streamObj["dict"].(map[string]any)
|
dict := streamObj["dict"].(map[string]any)
|
||||||
@@ -238,7 +239,7 @@ func TestSetStreamSubtype(t *testing.T) {
|
|||||||
}
|
}
|
||||||
updateObjects := make(map[string]any)
|
updateObjects := make(map[string]any)
|
||||||
|
|
||||||
setStreamSubtype(logger, objects, updateObjects, "5 0 R", "application/pdf")
|
setStreamSubtype(context.Background(), logger, objects, updateObjects, "5 0 R", "application/pdf")
|
||||||
|
|
||||||
if _, ok := updateObjects["obj:5 0 R"]; !ok {
|
if _, ok := updateObjects["obj:5 0 R"]; !ok {
|
||||||
t.Error("expected obj:5 0 R in updateObjects")
|
t.Error("expected obj:5 0 R in updateObjects")
|
||||||
@@ -249,7 +250,7 @@ func TestSetStreamSubtype(t *testing.T) {
|
|||||||
objects := map[string]json.RawMessage{}
|
objects := map[string]json.RawMessage{}
|
||||||
updateObjects := make(map[string]any)
|
updateObjects := make(map[string]any)
|
||||||
|
|
||||||
setStreamSubtype(logger, objects, updateObjects, "obj:99 0 R", "text/xml")
|
setStreamSubtype(context.Background(), logger, objects, updateObjects, "obj:99 0 R", "text/xml")
|
||||||
|
|
||||||
if len(updateObjects) != 0 {
|
if len(updateObjects) != 0 {
|
||||||
t.Error("expected no updates for missing object")
|
t.Error("expected no updates for missing object")
|
||||||
@@ -262,7 +263,7 @@ func TestSetStreamSubtype(t *testing.T) {
|
|||||||
}
|
}
|
||||||
updateObjects := make(map[string]any)
|
updateObjects := make(map[string]any)
|
||||||
|
|
||||||
setStreamSubtype(logger, objects, updateObjects, "obj:3 0 R", "text/xml")
|
setStreamSubtype(context.Background(), logger, objects, updateObjects, "obj:3 0 R", "text/xml")
|
||||||
|
|
||||||
if len(updateObjects) != 0 {
|
if len(updateObjects) != 0 {
|
||||||
t.Error("expected no updates for non-stream object")
|
t.Error("expected no updates for non-stream object")
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
|||||||
startTime: startTime,
|
startTime: startTime,
|
||||||
|
|
||||||
client: &retryablehttp.Client{
|
client: &retryablehttp.Client{
|
||||||
HTTPClient: gotenberg.NewOutboundHttpClient(w.clientTimeout, w.allowList, w.denyList, ipOpts...),
|
HTTPClient: gotenberg.NewOutboundHttpClient(w.clientTimeout, w.allowList, w.denyList, w.enableEnvironmentProxy, ipOpts...),
|
||||||
RetryMax: w.maxRetry,
|
RetryMax: w.maxRetry,
|
||||||
RetryWaitMin: w.retryMinWait,
|
RetryWaitMin: w.retryMinWait,
|
||||||
RetryWaitMax: w.retryMaxWait,
|
RetryWaitMax: w.retryMaxWait,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package webhook
|
package webhook
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,19 +19,20 @@ func init() {
|
|||||||
// Webhook is a module that provides a middleware for uploading output files
|
// Webhook is a module that provides a middleware for uploading output files
|
||||||
// to any destinations in an asynchronous fashion.
|
// to any destinations in an asynchronous fashion.
|
||||||
type Webhook struct {
|
type Webhook struct {
|
||||||
enableSyncMode bool
|
enableSyncMode bool
|
||||||
allowList []*regexp2.Regexp
|
allowList []*regexp2.Regexp
|
||||||
denyList []*regexp2.Regexp
|
denyList []*regexp2.Regexp
|
||||||
errorAllowList []*regexp2.Regexp
|
errorAllowList []*regexp2.Regexp
|
||||||
errorDenyList []*regexp2.Regexp
|
errorDenyList []*regexp2.Regexp
|
||||||
denyPrivateIPs bool
|
denyPrivateIPs bool
|
||||||
denyPublicIPs bool
|
denyPublicIPs bool
|
||||||
maxRetry int
|
enableEnvironmentProxy bool
|
||||||
retryMinWait time.Duration
|
maxRetry int
|
||||||
retryMaxWait time.Duration
|
retryMinWait time.Duration
|
||||||
clientTimeout time.Duration
|
retryMaxWait time.Duration
|
||||||
asyncCount atomic.Int64
|
clientTimeout time.Duration
|
||||||
disable bool
|
asyncCount atomic.Int64
|
||||||
|
disable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Descriptor returns an [Webhook]'s module descriptor.
|
// Descriptor returns an [Webhook]'s module descriptor.
|
||||||
@@ -44,6 +46,7 @@ func (w *Webhook) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
fs.StringSlice("webhook-deny-list", []string{}, "Set the denied URLs for the webhook feature using regular expressions - supports multiple values")
|
fs.StringSlice("webhook-deny-list", []string{}, "Set the denied URLs for the webhook feature using regular expressions - supports multiple values")
|
||||||
fs.Bool("webhook-deny-private-ips", false, "Reject webhook URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted webhook destinations to mitigate SSRF against internal services")
|
fs.Bool("webhook-deny-private-ips", false, "Reject webhook URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted webhook destinations to mitigate SSRF against internal services")
|
||||||
fs.Bool("webhook-deny-public-ips", false, "Reject webhook URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent callbacks from leaving a private network")
|
fs.Bool("webhook-deny-public-ips", false, "Reject webhook URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent callbacks from leaving a private network")
|
||||||
|
fs.Bool("webhook-enable-environment-proxy", false, "Route webhook callbacks through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials")
|
||||||
fs.Int("webhook-max-retry", 4, "Set the maximum number of retries for the webhook feature")
|
fs.Int("webhook-max-retry", 4, "Set the maximum number of retries for the webhook feature")
|
||||||
|
|
||||||
// Deprecated flags.
|
// Deprecated flags.
|
||||||
@@ -78,6 +81,7 @@ func (w *Webhook) Provision(ctx *gotenberg.Context) error {
|
|||||||
w.errorDenyList = flags.MustDeprecatedRegexpSlice("webhook-error-deny-list", "webhook-deny-list")
|
w.errorDenyList = flags.MustDeprecatedRegexpSlice("webhook-error-deny-list", "webhook-deny-list")
|
||||||
w.denyPrivateIPs = flags.MustBool("webhook-deny-private-ips")
|
w.denyPrivateIPs = flags.MustBool("webhook-deny-private-ips")
|
||||||
w.denyPublicIPs = flags.MustBool("webhook-deny-public-ips")
|
w.denyPublicIPs = flags.MustBool("webhook-deny-public-ips")
|
||||||
|
w.enableEnvironmentProxy = flags.MustBool("webhook-enable-environment-proxy")
|
||||||
w.maxRetry = flags.MustInt("webhook-max-retry")
|
w.maxRetry = flags.MustInt("webhook-max-retry")
|
||||||
w.retryMinWait = flags.MustDuration("webhook-retry-min-wait")
|
w.retryMinWait = flags.MustDuration("webhook-retry-min-wait")
|
||||||
w.retryMaxWait = flags.MustDuration("webhook-retry-max-wait")
|
w.retryMaxWait = flags.MustDuration("webhook-retry-max-wait")
|
||||||
@@ -104,10 +108,25 @@ func (w *Webhook) AsyncCount() int64 {
|
|||||||
return w.asyncCount.Load()
|
return w.asyncCount.Load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate checks the module's configuration.
|
||||||
|
func (w *Webhook) Validate() error {
|
||||||
|
if !w.enableEnvironmentProxy {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := gotenberg.ValidateEnvironmentProxyVariables()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("--webhook-enable-environment-proxy is set: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Interface guards.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ gotenberg.Module = (*Webhook)(nil)
|
_ gotenberg.Module = (*Webhook)(nil)
|
||||||
_ gotenberg.Provisioner = (*Webhook)(nil)
|
_ gotenberg.Provisioner = (*Webhook)(nil)
|
||||||
|
_ gotenberg.Validator = (*Webhook)(nil)
|
||||||
_ api.MiddlewareProvider = (*Webhook)(nil)
|
_ api.MiddlewareProvider = (*Webhook)(nil)
|
||||||
_ api.AsynchronousCounter = (*Webhook)(nil)
|
_ api.AsynchronousCounter = (*Webhook)(nil)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ make test-integration PLATFORM=linux/arm64 # force a specific platform
|
|||||||
|
|
||||||
Available tags:
|
Available tags:
|
||||||
|
|
||||||
| Group | Tags |
|
| Group | Tags |
|
||||||
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Chromium | `chromium`, `chromium-concurrent`, `chromium-convert-html`, `chromium-convert-markdown`, `chromium-convert-url`, `chromium-screenshot-html`, `chromium-screenshot-markdown`, `chromium-screenshot-url` |
|
| Chromium | `chromium`, `chromium-concurrent`, `chromium-convert-html`, `chromium-convert-markdown`, `chromium-convert-url`, `chromium-screenshot-html`, `chromium-screenshot-markdown`, `chromium-screenshot-url`, `chromium-ssrf` |
|
||||||
| LibreOffice | `libreoffice`, `libreoffice-convert` |
|
| LibreOffice | `libreoffice`, `libreoffice-convert`, `libreoffice-ssrf` |
|
||||||
| PDF Engines | `pdfengines`, `pdfengines-convert`, `pdfengines-merge`, `merge`, `pdfengines-split`, `split`, `pdfengines-flatten`, `flatten`, `pdfengines-rotate`, `rotate`, `pdfengines-embed`, `embed`, `pdfengines-encrypt`, `encrypt`, `pdfengines-watermark`, `watermark`, `pdfengines-stamp`, `stamp`, `pdfengines-metadata`, `metadata`, `pdfengines-bookmarks`, `bookmarks` |
|
| PDF Engines | `pdfengines`, `pdfengines-convert`, `pdfengines-merge`, `merge`, `pdfengines-split`, `split`, `pdfengines-flatten`, `flatten`, `pdfengines-optimize`, `optimize`, `pdfengines-rotate`, `rotate`, `pdfengines-embed`, `embed`, `pdfengines-encrypt`, `encrypt`, `pdfengines-watermark`, `watermark`, `pdfengines-stamp`, `stamp`, `pdfengines-metadata`, `metadata`, `pdfengines-bookmarks`, `bookmarks` |
|
||||||
| Infra | `health`, `debug`, `root`, `version`, `output-filename`, `prometheus-metrics`, `webhook`, `download-from` |
|
| Infra | `health`, `debug`, `root`, `version`, `output-filename`, `prometheus-metrics`, `webhook`, `download-from` |
|
||||||
|
|
||||||
## Writing a new test
|
## Writing a new test
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,24 @@ Feature: /forms/chromium/convert/html
|
|||||||
Page 12
|
Page 12
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# A wide table on one landscape page: the page must expand its width to the
|
||||||
|
# content, so the rightmost column is not truncated and the page comes out
|
||||||
|
# landscape instead of the narrow-tall strip the height-only expansion used
|
||||||
|
# to produce. See https://github.com/gotenberg/gotenberg/issues/1390.
|
||||||
|
Scenario: POST /forms/chromium/convert/html (Single Page Landscape)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/wide-table-html/index.html | file |
|
||||||
|
| singlePage | true | field |
|
||||||
|
| landscape | true | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
|
Then there should be 1 PDF(s) in the response
|
||||||
|
Then the "foo.pdf" PDF should have 1 page(s)
|
||||||
|
Then the "foo.pdf" PDF should be set to landscape orientation
|
||||||
|
Then the "foo.pdf" PDF should have content matching "Column 12" at page 1
|
||||||
|
|
||||||
Scenario: POST /forms/chromium/convert/html (Landscape)
|
Scenario: POST /forms/chromium/convert/html (Landscape)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
@@ -195,6 +213,26 @@ Feature: /forms/chromium/convert/html
|
|||||||
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
|
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# A thenable (async) expression is awaited and its resolved value gates the
|
||||||
|
# print. Without awaiting, the Promise object never evaluates to true and the
|
||||||
|
# request would time out. See https://github.com/gotenberg/gotenberg/pull/1617.
|
||||||
|
Scenario: POST /forms/chromium/convert/html (Wait For Thenable Expression)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/feature-rich-html/index.html | file |
|
||||||
|
| waitForExpression | (async () => window.globalVar === 'ready')() | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
|
Then there should be 1 PDF(s) in the response
|
||||||
|
Then there should be the following file(s) in the response:
|
||||||
|
| foo.pdf |
|
||||||
|
Then the "foo.pdf" PDF should have 1 page(s)
|
||||||
|
Then the "foo.pdf" PDF should have the following content at page 1:
|
||||||
|
"""
|
||||||
|
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
|
||||||
|
"""
|
||||||
|
|
||||||
Scenario: POST /forms/chromium/convert/html (rAF / ResizeObserver / IntersectionObserver fire with waitForExpression)
|
Scenario: POST /forms/chromium/convert/html (rAF / ResizeObserver / IntersectionObserver fire with waitForExpression)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
@@ -416,6 +454,87 @@ Feature: /forms/chromium/convert/html
|
|||||||
Then the Gotenberg container should log the following entries:
|
Then the Gotenberg container should log the following entries:
|
||||||
| 'file:///etc/passwd' matches the expression from the denied list |
|
| 'file:///etc/passwd' matches the expression from the denied list |
|
||||||
|
|
||||||
|
# Control for the WebSocket scenario below. An ordinary fetch to a loopback
|
||||||
|
# address is surfaced as a Fetch.requestPaused event, so it is blocked by
|
||||||
|
# CHROMIUM_DENY_PRIVATE_IPS and the block is logged. The allow-list is
|
||||||
|
# cleared because a matching allow-list entry bypasses the IP-based check.
|
||||||
|
@chromium-ssrf
|
||||||
|
Scenario: POST /forms/chromium/convert/html (Fetch to a non-public address is filtered)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_ALLOW_LIST | |
|
||||||
|
| CHROMIUM_DENY_PRIVATE_IPS | true |
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/ssrf-fetch-html/index.html | file |
|
||||||
|
| waitDelay | 1s | field |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the Gotenberg container should log the following entries:
|
||||||
|
| 'http://127.0.0.1:9999/ssrf-fetch' targets a non-public address |
|
||||||
|
|
||||||
|
# A WebSocket handshake is never surfaced as a Fetch.requestPaused event, so
|
||||||
|
# it escapes the filter in listenForEventRequestPaused. The page opens
|
||||||
|
# WebSockets to two non-public addresses (loopback and the link-local cloud
|
||||||
|
# metadata IP). listenForEventWebSocketCreated logs each disallowed handshake
|
||||||
|
# with its full ws:// URL (detection), and the pinning proxy severs the
|
||||||
|
# connection now that the implicit loopback bypass is removed (enforcement).
|
||||||
|
@chromium-ssrf
|
||||||
|
Scenario: POST /forms/chromium/convert/html (WebSocket to a non-public address is filtered)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_ALLOW_LIST | |
|
||||||
|
| CHROMIUM_DENY_PRIVATE_IPS | true |
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/ssrf-websocket-html/index.html | file |
|
||||||
|
| waitDelay | 1s | field |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the Gotenberg container should log the following entries:
|
||||||
|
| 'ws://127.0.0.1:9999/ssrf-websocket' targets a non-public address |
|
||||||
|
| CONNECT blocked for '127.0.0.1:9999' |
|
||||||
|
|
||||||
|
# A Web Worker is a separate CDP target, so its WebSocket handshake is not
|
||||||
|
# observed by listenForEventWebSocketCreated. Enforcement must not depend on
|
||||||
|
# that listener: the pinning proxy sees the handshake and severs it whatever
|
||||||
|
# the originating context. Only the proxy's block is asserted, since no
|
||||||
|
# detection log is produced for the worker target.
|
||||||
|
@chromium-ssrf
|
||||||
|
Scenario: POST /forms/chromium/convert/html (WebSocket from a Web Worker is filtered)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_ALLOW_LIST | |
|
||||||
|
| CHROMIUM_DENY_PRIVATE_IPS | true |
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/ssrf-websocket-worker-html/index.html | file |
|
||||||
|
| waitDelay | 1s | field |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the Gotenberg container should log the following entries:
|
||||||
|
| CONNECT blocked for '127.0.0.1:9999' |
|
||||||
|
|
||||||
|
# wss:// (TLS) handshakes tunnel through the proxy via CONNECT, the same path
|
||||||
|
# as ws://, and must be filtered identically.
|
||||||
|
@chromium-ssrf
|
||||||
|
Scenario: POST /forms/chromium/convert/html (Secure WebSocket to a non-public address is filtered)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_ALLOW_LIST | |
|
||||||
|
| CHROMIUM_DENY_PRIVATE_IPS | true |
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/ssrf-websocket-tls-html/index.html | file |
|
||||||
|
| waitDelay | 1s | field |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the Gotenberg container should log the following entries:
|
||||||
|
| 'wss://127.0.0.1:9999/wss-test' targets a non-public address |
|
||||||
|
| CONNECT blocked for '127.0.0.1:9999' |
|
||||||
|
|
||||||
|
# EventSource issues an ordinary HTTP GET, so unlike a WebSocket it IS surfaced
|
||||||
|
# as a fetch.EventRequestPaused and blocked by listenForEventRequestPaused.
|
||||||
|
@chromium-ssrf
|
||||||
|
Scenario: POST /forms/chromium/convert/html (EventSource to a non-public address is filtered)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_ALLOW_LIST | |
|
||||||
|
| CHROMIUM_DENY_PRIVATE_IPS | true |
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/ssrf-eventsource-html/index.html | file |
|
||||||
|
| waitDelay | 1s | field |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the Gotenberg container should log the following entries:
|
||||||
|
| 'http://127.0.0.1:9999/sse' targets a non-public address |
|
||||||
|
|
||||||
Scenario: POST /forms/chromium/convert/html (Main URL does NOT match allowed list)
|
Scenario: POST /forms/chromium/convert/html (Main URL does NOT match allowed list)
|
||||||
Given I have a Gotenberg container with the following environment variable(s):
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
| CHROMIUM_ALLOW_LIST | ^file:(?!//\\/tmp/).* |
|
| CHROMIUM_ALLOW_LIST | ^file:(?!//\\/tmp/).* |
|
||||||
@@ -548,6 +667,25 @@ Feature: /forms/chromium/convert/html
|
|||||||
Error: Exception 2
|
Error: Exception 2
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
Scenario: POST /forms/chromium/convert/html (Document Outline)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/page-outline-html/index.html | file |
|
||||||
|
| generateDocumentOutline | true | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
|
Then there should be 1 PDF(s) in the response
|
||||||
|
# generateTaggedPdf is left unset, yet the outline must still be embedded:
|
||||||
|
# Gotenberg enables tagged PDF automatically because Chromium derives the
|
||||||
|
# outline from the structure tree. See issue #1579.
|
||||||
|
Then the "foo.pdf" PDF should have a document outline
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/page-outline-html/index.html | file |
|
||||||
|
| Gotenberg-Output-Filename | bar | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the "bar.pdf" PDF should NOT have a document outline
|
||||||
|
|
||||||
Scenario: POST /forms/chromium/convert/html (Bad Request)
|
Scenario: POST /forms/chromium/convert/html (Bad Request)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
@@ -794,8 +932,40 @@ Feature: /forms/chromium/convert/html
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# See https://github.com/gotenberg/gotenberg/issues/1130.
|
# See https://github.com/gotenberg/gotenberg/issues/1130.
|
||||||
|
# A backslash is not a path separator on Linux, so filepath.Base leaves it in
|
||||||
|
# place and it reaches the archive entry names. See GHSA-hwc4-gmrw-5222.
|
||||||
@split
|
@split
|
||||||
@output-filename
|
@output-filename
|
||||||
|
Scenario: POST /forms/chromium/convert/html (Split Windows Path As Output Filename)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/pages-3-html/index.html | file |
|
||||||
|
| splitMode | intervals | field |
|
||||||
|
| splitSpan | 2 | field |
|
||||||
|
| Gotenberg-Output-Filename | ..\\..\\..\\Windows\\System32\\foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "application/zip"
|
||||||
|
Then there should be 2 PDF(s) in the response
|
||||||
|
Then there should be the following file(s) in the response:
|
||||||
|
| foo.zip |
|
||||||
|
| foo_0.pdf |
|
||||||
|
| foo_1.pdf |
|
||||||
|
|
||||||
|
Scenario: POST /forms/chromium/convert/html (Split Rooted Windows Path As Output Filename)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/pages-3-html/index.html | file |
|
||||||
|
| splitMode | intervals | field |
|
||||||
|
| splitSpan | 2 | field |
|
||||||
|
| Gotenberg-Output-Filename | C:\\Windows\\Temp\\foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "application/zip"
|
||||||
|
Then there should be 2 PDF(s) in the response
|
||||||
|
Then there should be the following file(s) in the response:
|
||||||
|
| foo.zip |
|
||||||
|
| foo_0.pdf |
|
||||||
|
| foo_1.pdf |
|
||||||
|
|
||||||
Scenario: POST /forms/chromium/convert/html (Split Output Filename)
|
Scenario: POST /forms/chromium/convert/html (Split Output Filename)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
@@ -1037,6 +1207,26 @@ Feature: /forms/chromium/convert/html
|
|||||||
Then there should be 1 PDF(s) in the response
|
Then there should be 1 PDF(s) in the response
|
||||||
Then the response PDF(s) should be flatten
|
Then the response PDF(s) should be flatten
|
||||||
|
|
||||||
|
# Post-processing image optimization re-encodes the embedded lossless image to
|
||||||
|
# JPEG. The same page is ~700 KB without it and well under 300 KB with it.
|
||||||
|
# See https://github.com/gotenberg/gotenberg/issues/359.
|
||||||
|
Scenario: POST /forms/chromium/convert/html (Optimize Images)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/optimize-image-html/index.html | file |
|
||||||
|
| files | testdata/optimize-image-html/image.png | file |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the "foo.pdf" file size should be greater than 300 KB
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/optimize-image-html/index.html | file |
|
||||||
|
| files | testdata/optimize-image-html/image.png | file |
|
||||||
|
| optimizeImages | true | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
|
Then the "foo.pdf" file size should be less than 300 KB
|
||||||
|
|
||||||
@encrypt
|
@encrypt
|
||||||
Scenario: POST /forms/chromium/convert/html (Encrypt - user password only)
|
Scenario: POST /forms/chromium/convert/html (Encrypt - user password only)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
|
|||||||
@@ -22,6 +22,41 @@ Feature: /forms/chromium/convert/url
|
|||||||
Page 1
|
Page 1
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# localStorage is per-origin and shared by the long-lived browser, so without
|
||||||
|
# clearing it accumulates across same-origin conversions.
|
||||||
|
# See https://github.com/gotenberg/gotenberg/issues/919.
|
||||||
|
Scenario: POST /forms/chromium/convert/url (localStorage leaks without clearing)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
Given I have a static server
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||||
|
| url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the "foo.pdf" PDF should have content matching "localStorageCount=1" at page 1
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||||
|
| url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the "foo.pdf" PDF should have content matching "localStorageCount=2" at page 1
|
||||||
|
|
||||||
|
# With --chromium-clear-storage every conversion clears the origin's
|
||||||
|
# localStorage first, so the counter never carries over.
|
||||||
|
# See https://github.com/gotenberg/gotenberg/issues/919.
|
||||||
|
Scenario: POST /forms/chromium/convert/url (Clear Storage)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_CLEAR_STORAGE | true |
|
||||||
|
Given I have a static server
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||||
|
| url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the "foo.pdf" PDF should have content matching "localStorageCount=1" at page 1
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||||
|
| url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the "foo.pdf" PDF should have content matching "localStorageCount=1" at page 1
|
||||||
|
|
||||||
Scenario: POST /forms/chromium/convert/url (Single Page)
|
Scenario: POST /forms/chromium/convert/url (Single Page)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
Given I have a static server
|
Given I have a static server
|
||||||
@@ -499,6 +534,7 @@ Feature: /forms/chromium/convert/url
|
|||||||
Then the response header "Content-Type" should be "application/pdf"
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
Then there should be 1 PDF(s) in the response
|
Then there should be 1 PDF(s) in the response
|
||||||
|
|
||||||
|
@chromium-ssrf
|
||||||
Scenario: POST /forms/chromium/convert/url (Main URL is a non-public IP literal, deny-private-ips on)
|
Scenario: POST /forms/chromium/convert/url (Main URL is a non-public IP literal, deny-private-ips on)
|
||||||
Given I have a Gotenberg container with the following environment variable(s):
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
| CHROMIUM_ALLOW_LIST | |
|
| CHROMIUM_ALLOW_LIST | |
|
||||||
@@ -512,6 +548,55 @@ Feature: /forms/chromium/convert/url
|
|||||||
Forbidden
|
Forbidden
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# IPv6 loopback literal is parsed as an IP and rejected by the IP-class check,
|
||||||
|
# like the IPv4 loopback literal above.
|
||||||
|
@chromium-ssrf
|
||||||
|
Scenario: POST /forms/chromium/convert/url (Main URL is an IPv6 loopback literal, deny-private-ips on)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_ALLOW_LIST | |
|
||||||
|
| CHROMIUM_DENY_PRIVATE_IPS | true |
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||||
|
| url | http://[::1]/ | field |
|
||||||
|
Then the response status code should be 403
|
||||||
|
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
|
Then the response body should match string:
|
||||||
|
"""
|
||||||
|
Forbidden
|
||||||
|
"""
|
||||||
|
|
||||||
|
# An alternate IP encoding (decimal for 127.0.0.1) that Chromium would read
|
||||||
|
# as loopback but the resolver rejects as a hostname. It must fail closed as
|
||||||
|
# filtered (a generic 403), not surface as a 500.
|
||||||
|
@chromium-ssrf
|
||||||
|
Scenario: POST /forms/chromium/convert/url (Main URL is a decimal-encoded loopback IP, deny-private-ips on)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_ALLOW_LIST | |
|
||||||
|
| CHROMIUM_DENY_PRIVATE_IPS | true |
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||||
|
| url | http://2130706433/ | field |
|
||||||
|
Then the response status code should be 403
|
||||||
|
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
|
Then the response body should match string:
|
||||||
|
"""
|
||||||
|
Forbidden
|
||||||
|
"""
|
||||||
|
|
||||||
|
# A classic SSRF vector: an allow-listed URL that redirects to an internal
|
||||||
|
# address. The redirected request must not inherit the initial URL's
|
||||||
|
# allow-list pass. listenForEventRequestPaused re-validates it; it does not
|
||||||
|
# match the allow-list, so it is blocked (Chromium reports ERR_ACCESS_DENIED
|
||||||
|
# and the conversion renders the resulting error page).
|
||||||
|
@chromium-ssrf
|
||||||
|
Scenario: POST /forms/chromium/convert/url (Redirect to a non-allow-listed address is re-filtered)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_ALLOW_LIST | ^https?://host.docker.internal.* |
|
||||||
|
Given I have a static server
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||||
|
| url | http://host.docker.internal:%d/redirect-to-private | field |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the Gotenberg container should log the following entries:
|
||||||
|
| 'http://127.0.0.1:9999/redirected' does not match any expression from the allowed list |
|
||||||
|
|
||||||
Scenario: POST /forms/chromium/convert/url (Main URL resolves to a non-public IP, deny-private-ips on with allow-list bypass)
|
Scenario: POST /forms/chromium/convert/url (Main URL resolves to a non-public IP, deny-private-ips on with allow-list bypass)
|
||||||
Given I have a Gotenberg container with the following environment variable(s):
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
| CHROMIUM_ALLOW_LIST | .+ |
|
| CHROMIUM_ALLOW_LIST | .+ |
|
||||||
|
|||||||
@@ -2,6 +2,22 @@
|
|||||||
@chromium-screenshot-html
|
@chromium-screenshot-html
|
||||||
Feature: /forms/chromium/screenshot/html
|
Feature: /forms/chromium/screenshot/html
|
||||||
|
|
||||||
|
# Route parity: the WebSocket outbound filter lives in the shared browser
|
||||||
|
# code path, so the screenshot route enforces it exactly like conversion.
|
||||||
|
@chromium-ssrf
|
||||||
|
Scenario: POST /forms/chromium/screenshot/html (WebSocket to a non-public address is filtered)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_ALLOW_LIST | |
|
||||||
|
| CHROMIUM_DENY_PRIVATE_IPS | true |
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/ssrf-websocket-html/index.html | file |
|
||||||
|
| waitDelay | 1s | field |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "image/png"
|
||||||
|
Then the Gotenberg container should log the following entries:
|
||||||
|
| 'ws://127.0.0.1:9999/ssrf-websocket' targets a non-public address |
|
||||||
|
| CONNECT blocked for '127.0.0.1:9999' |
|
||||||
|
|
||||||
Scenario: POST /forms/chromium/screenshot/html (Default)
|
Scenario: POST /forms/chromium/screenshot/html (Default)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
||||||
@@ -53,6 +69,32 @@ Feature: /forms/chromium/screenshot/html
|
|||||||
Then the response status code should be 200
|
Then the response status code should be 200
|
||||||
Then the response header "Content-Type" should be "image/png"
|
Then the response header "Content-Type" should be "image/png"
|
||||||
|
|
||||||
|
# The target element is 300x200 and sits below a spacer, so a correct clip
|
||||||
|
# proves both the element size and its page offset. See issue #947.
|
||||||
|
Scenario: POST /forms/chromium/screenshot/html (Selector)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/screenshot-selector-html/index.html | file |
|
||||||
|
| selector | #target | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "image/png"
|
||||||
|
Then the "foo.png" image should be 300x200 pixels
|
||||||
|
# A centered red pixel proves the clip landed on the element, not on the
|
||||||
|
# white spacer above it, i.e. the page offset was applied.
|
||||||
|
Then the "foo.png" image pixel at 150,100 should be "#ff0000"
|
||||||
|
|
||||||
|
Scenario: POST /forms/chromium/screenshot/html (Selector Not Found)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/screenshot-selector-html/index.html | file |
|
||||||
|
| selector | #does-not-exist | field |
|
||||||
|
Then the response status code should be 400
|
||||||
|
Then the response body should contain string:
|
||||||
|
"""
|
||||||
|
The selector '#does-not-exist' (selector) matched no element with a visible box
|
||||||
|
"""
|
||||||
|
|
||||||
Scenario: POST /forms/chromium/screenshot/html (Quality)
|
Scenario: POST /forms/chromium/screenshot/html (Quality)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ Feature: /debug
|
|||||||
"chromium-auto-start": "false",
|
"chromium-auto-start": "false",
|
||||||
"chromium-clear-cache": "false",
|
"chromium-clear-cache": "false",
|
||||||
"chromium-clear-cookies": "false",
|
"chromium-clear-cookies": "false",
|
||||||
|
"chromium-clear-storage": "false",
|
||||||
"chromium-deny-list": "[^file:(?!//\\/tmp/).*]",
|
"chromium-deny-list": "[^file:(?!//\\/tmp/).*]",
|
||||||
"chromium-deny-private-ips": "false",
|
"chromium-deny-private-ips": "false",
|
||||||
"chromium-deny-public-ips": "false",
|
"chromium-deny-public-ips": "false",
|
||||||
@@ -221,6 +222,7 @@ Feature: /debug
|
|||||||
"chromium-auto-start": "false",
|
"chromium-auto-start": "false",
|
||||||
"chromium-clear-cache": "false",
|
"chromium-clear-cache": "false",
|
||||||
"chromium-clear-cookies": "false",
|
"chromium-clear-cookies": "false",
|
||||||
|
"chromium-clear-storage": "false",
|
||||||
"chromium-deny-list": "[^file:(?!//\\/tmp/).*]",
|
"chromium-deny-list": "[^file:(?!//\\/tmp/).*]",
|
||||||
"chromium-deny-private-ips": "false",
|
"chromium-deny-private-ips": "false",
|
||||||
"chromium-deny-public-ips": "false",
|
"chromium-deny-public-ips": "false",
|
||||||
|
|||||||
@@ -18,6 +18,33 @@ Feature: /forms/libreoffice/convert
|
|||||||
Page 1
|
Page 1
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# LibreOffice detects OOXML PowerPoint shows from content and converts them
|
||||||
|
# like .pptx; only Gotenberg's extension allow list gated them out.
|
||||||
|
# See https://github.com/gotenberg/gotenberg/pull/1626.
|
||||||
|
Scenario: POST /forms/libreoffice/convert (PowerPoint Show .ppsx)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/slideshow.ppsx | file |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
|
Then there should be 1 PDF(s) in the response
|
||||||
|
Then there should be the following file(s) in the response:
|
||||||
|
| foo.pdf |
|
||||||
|
Then the "foo.pdf" PDF should have 1 page(s)
|
||||||
|
|
||||||
|
Scenario: POST /forms/libreoffice/convert (PowerPoint Show .ppsm)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/slideshow.ppsm | file |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
|
Then there should be 1 PDF(s) in the response
|
||||||
|
Then there should be the following file(s) in the response:
|
||||||
|
| foo.pdf |
|
||||||
|
Then the "foo.pdf" PDF should have 1 page(s)
|
||||||
|
|
||||||
# A CSV becomes a single Calc sheet named after the input file, and Calc's
|
# A CSV becomes a single Calc sheet named after the input file, and Calc's
|
||||||
# default page style prints that sheet name as a centered header. Uploads are
|
# default page style prints that sheet name as a centered header. Uploads are
|
||||||
# stored under a UUID-based filename, so the UUID must not leak into the PDF.
|
# stored under a UUID-based filename, so the UUID must not leak into the PDF.
|
||||||
@@ -88,7 +115,7 @@ Feature: /forms/libreoffice/convert
|
|||||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
Then the response body should match string:
|
||||||
"""
|
"""
|
||||||
LibreOffice failed to process a document: a password may be required, or, if one has been given, it is invalid. In any case, the exact cause is uncertain.
|
The document 'protected_page_1.docx' is password-protected. Provide its password in the 'password' form field.
|
||||||
"""
|
"""
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||||
| files | testdata/protected_page_1.docx | file |
|
| files | testdata/protected_page_1.docx | file |
|
||||||
@@ -211,7 +238,7 @@ Feature: /forms/libreoffice/convert
|
|||||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
Then the response body should match string:
|
||||||
"""
|
"""
|
||||||
Invalid form data: no form file found for extensions: [.123 .602 .abw .bib .bmp .cdr .cgm .cmx .csv .cwk .dbf .dif .doc .docm .docx .dot .dotm .dotx .dxf .emf .eps .epub .fodg .fodp .fods .fodt .fopd .gif .htm .html .hwp .jpeg .jpg .key .ltx .lwp .mcw .met .mml .mw .numbers .odd .odg .odm .odp .ods .odt .otg .oth .otp .ots .ott .pages .pbm .pcd .pct .pcx .pdb .pdf .pgm .png .pot .potm .potx .ppm .pps .ppt .pptm .pptx .psd .psw .pub .pwp .pxl .ras .rtf .sda .sdc .sdd .sdp .sdw .sgl .slk .smf .stc .std .sti .stw .svg .svm .swf .sxc .sxd .sxg .sxi .sxm .sxw .tga .tif .tiff .txt .uof .uop .uos .uot .vdx .vor .vsd .vsdm .vsdx .wb2 .wk1 .wks .wmf .wpd .wpg .wps .xbm .xhtml .xls .xlsb .xlsm .xlsx .xlt .xltm .xltx .xlw .xml .xpm .zabw]
|
Invalid form data: no form file found for extensions: [.123 .602 .abw .bib .bmp .cdr .cgm .cmx .csv .cwk .dbf .dif .doc .docm .docx .dot .dotm .dotx .dxf .emf .eps .epub .fodg .fodp .fods .fodt .fopd .gif .htm .html .hwp .jpeg .jpg .key .ltx .lwp .mcw .met .mml .mw .numbers .odd .odg .odm .odp .ods .odt .otg .oth .otp .ots .ott .pages .pbm .pcd .pct .pcx .pdb .pdf .pgm .png .pot .potm .potx .ppm .pps .ppsm .ppsx .ppt .pptm .pptx .psd .psw .pub .pwp .pxl .ras .rtf .sda .sdc .sdd .sdp .sdw .sgl .slk .smf .stc .std .sti .stw .svg .svm .swf .sxc .sxd .sxg .sxi .sxm .sxw .tga .tif .tiff .txt .uof .uop .uos .uot .vdx .vor .vsd .vsdm .vsdx .wb2 .wk1 .wks .wmf .wpd .wpg .wps .xbm .xhtml .xls .xlsb .xlsm .xlsx .xlt .xltm .xltx .xlw .xml .xpm .zabw]
|
||||||
form field 'landscape' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
|
form field 'landscape' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
|
||||||
form field 'exportFormFields' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
|
form field 'exportFormFields' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
|
||||||
form field 'allowDuplicateFieldNames' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
|
form field 'allowDuplicateFieldNames' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
|
||||||
@@ -255,7 +282,7 @@ Feature: /forms/libreoffice/convert
|
|||||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
Then the response body should match string:
|
||||||
"""
|
"""
|
||||||
LibreOffice failed to process a document: possible causes include malformed page ranges 'foo' (nativePageRanges), or, if a password has been provided, it may not be required. In any case, the exact cause is uncertain.
|
LibreOffice could not apply the page ranges 'foo' to the document 'page_1.docx'. Check the 'nativePageRanges' form field; valid values look like '1-4', '2' or '1,3,5-7'.
|
||||||
"""
|
"""
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||||
| files | testdata/page_1.docx | file |
|
| files | testdata/page_1.docx | file |
|
||||||
@@ -264,7 +291,7 @@ Feature: /forms/libreoffice/convert
|
|||||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
Then the response body should match string:
|
||||||
"""
|
"""
|
||||||
LibreOffice failed to process a document: possible causes include malformed page ranges '' (nativePageRanges), or, if a password has been provided, it may not be required. In any case, the exact cause is uncertain.
|
The document 'page_1.docx' is not password-protected. Remove the 'password' form field.
|
||||||
"""
|
"""
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||||
| files | testdata/protected_page_1.docx | file |
|
| files | testdata/protected_page_1.docx | file |
|
||||||
@@ -273,7 +300,7 @@ Feature: /forms/libreoffice/convert
|
|||||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
Then the response body should match string:
|
||||||
"""
|
"""
|
||||||
LibreOffice failed to process a document: a password may be required, or, if one has been given, it is invalid. In any case, the exact cause is uncertain.
|
The password for the document 'protected_page_1.docx' is incorrect. Check the 'password' form field.
|
||||||
"""
|
"""
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||||
| files | testdata/page_1.docx | file |
|
| files | testdata/page_1.docx | file |
|
||||||
@@ -942,7 +969,7 @@ Feature: /forms/libreoffice/convert
|
|||||||
|
|
||||||
# An embedded image is stored inside the document, not linked, so blocking
|
# An embedded image is stored inside the document, not linked, so blocking
|
||||||
# untrusted linked content leaves it untouched. Guards against over-blocking.
|
# untrusted linked content leaves it untouched. Guards against over-blocking.
|
||||||
@libreoffice-linked-content
|
@libreoffice-ssrf
|
||||||
Scenario: POST /forms/libreoffice/convert (Embedded Image Survives)
|
Scenario: POST /forms/libreoffice/convert (Embedded Image Survives)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||||
@@ -955,7 +982,7 @@ Feature: /forms/libreoffice/convert
|
|||||||
# An uploaded document always loads from an untrusted location, so soffice
|
# An uploaded document always loads from an untrusted location, so soffice
|
||||||
# refuses to resolve any content it links (absolute file:// path or external
|
# refuses to resolve any content it links (absolute file:// path or external
|
||||||
# URL). Closes the SSRF and local-file-read vector.
|
# URL). Closes the SSRF and local-file-read vector.
|
||||||
@libreoffice-linked-content
|
@libreoffice-ssrf
|
||||||
Scenario: POST /forms/libreoffice/convert (Linked External Resource Blocked)
|
Scenario: POST /forms/libreoffice/convert (Linked External Resource Blocked)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||||
@@ -964,3 +991,17 @@ Feature: /forms/libreoffice/convert
|
|||||||
Then the response status code should be 200
|
Then the response status code should be 200
|
||||||
Then there should be 1 PDF(s) in the response
|
Then there should be 1 PDF(s) in the response
|
||||||
Then the "foo.pdf" PDF should have 0 image(s)
|
Then the "foo.pdf" PDF should have 0 image(s)
|
||||||
|
|
||||||
|
# The workbook was saved scrolled down to row 37. Without the topLeftCell
|
||||||
|
# reset, SinglePageSheets would start the page there and drop the header rows,
|
||||||
|
# so the "Meteor" column header only appears when the whole sheet is rendered.
|
||||||
|
# See https://github.com/gotenberg/gotenberg/issues/1222.
|
||||||
|
Scenario: POST /forms/libreoffice/convert (SinglePageSheets renders a scrolled workbook in full)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/singlepagesheets-scrolled.xlsx | file |
|
||||||
|
| singlePageSheets | true | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then there should be 1 PDF(s) in the response
|
||||||
|
Then the "foo.pdf" PDF should have content matching "Meteor" at page 1
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ Feature: Output Filename
|
|||||||
Then there should be the following file(s) in the response:
|
Then there should be the following file(s) in the response:
|
||||||
| foo.zip |
|
| foo.zip |
|
||||||
|
|
||||||
|
# See GHSA-hwc4-gmrw-5222.
|
||||||
|
Scenario: Windows Path As Filename
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/page_1.pdf | file |
|
||||||
|
| Gotenberg-Output-Filename | C:\\Windows\\Temp\\foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
|
Then there should be the following file(s) in the response:
|
||||||
|
| foo.pdf |
|
||||||
|
|
||||||
# See https://github.com/gotenberg/gotenberg/issues/1227.
|
# See https://github.com/gotenberg/gotenberg/issues/1227.
|
||||||
Scenario: Path As Filename
|
Scenario: Path As Filename
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
|
|||||||
@@ -291,6 +291,49 @@ Feature: /forms/pdfengines/merge
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# titleBookmarks adds a top-level bookmark per merged document, labeled by its
|
||||||
|
# Title metadata (falling back to the filename) and pointing to its first page,
|
||||||
|
# with the document's own outline nested underneath.
|
||||||
|
# See https://github.com/gotenberg/gotenberg/issues/867.
|
||||||
|
@bookmarks
|
||||||
|
Scenario: POST /forms/pdfengines/merge (Title Bookmarks)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/titled_alpha.pdf | file |
|
||||||
|
| files | testdata/titled_bravo.pdf | file |
|
||||||
|
| files | testdata/untitled_gamma.pdf | file |
|
||||||
|
| titleBookmarks | true | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s):
|
||||||
|
| files | teststore/foo.pdf | file |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "application/json"
|
||||||
|
Then the response body should match JSON:
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"foo.pdf": [
|
||||||
|
{
|
||||||
|
"title": "Alpha",
|
||||||
|
"page": 1,
|
||||||
|
"children": [
|
||||||
|
{ "title": "A1", "page": 1 },
|
||||||
|
{ "title": "A2", "page": 2 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Bravo",
|
||||||
|
"page": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "untitled_gamma",
|
||||||
|
"page": 4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
@bookmarks
|
@bookmarks
|
||||||
Scenario: POST /forms/pdfengines/merge (Auto-index Bookmarks)
|
Scenario: POST /forms/pdfengines/merge (Auto-index Bookmarks)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
|
|||||||
61
test/integration/features/pdfengines_optimize.feature
Normal file
61
test/integration/features/pdfengines_optimize.feature
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
@pdfengines
|
||||||
|
@pdfengines-optimize
|
||||||
|
@optimize
|
||||||
|
Feature: /forms/pdfengines/optimize
|
||||||
|
|
||||||
|
# image-heavy.pdf is a ~710 KB PDF whose single image Chromium embedded
|
||||||
|
# losslessly (FlateDecode). Re-encoding it to JPEG shrinks the file well
|
||||||
|
# below this threshold while leaving the structure intact.
|
||||||
|
# See https://github.com/gotenberg/gotenberg/issues/359.
|
||||||
|
Scenario: POST /forms/pdfengines/optimize (Image-heavy PDF)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/optimize" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/image-heavy.pdf | file |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
|
Then there should be 1 PDF(s) in the response
|
||||||
|
Then the "foo.pdf" file size should be less than 300 KB
|
||||||
|
|
||||||
|
Scenario: POST /forms/pdfengines/optimize (Custom Image Quality)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/optimize" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/image-heavy.pdf | file |
|
||||||
|
| imageQuality | 40 | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the "foo.pdf" file size should be less than 300 KB
|
||||||
|
|
||||||
|
Scenario: POST /forms/pdfengines/optimize (PDF Without Images)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/optimize" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/page_1.pdf | file |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
|
Then there should be 1 PDF(s) in the response
|
||||||
|
|
||||||
|
Scenario: POST /forms/pdfengines/optimize (Invalid Image Quality)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/optimize" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/image-heavy.pdf | file |
|
||||||
|
| imageQuality | 200 | field |
|
||||||
|
Then the response status code should be 400
|
||||||
|
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
|
|
||||||
|
Scenario: POST /forms/pdfengines/optimize (Bad Request)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/optimize" endpoint with the following form data and header(s):
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 400
|
||||||
|
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
|
Then the response body should match string:
|
||||||
|
"""
|
||||||
|
Invalid form data: no form file found for extensions: [.pdf]
|
||||||
|
"""
|
||||||
|
|
||||||
|
Scenario: POST /forms/pdfengines/optimize (Routes Disabled)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| PDFENGINES_DISABLE_ROUTES | true |
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/optimize" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/image-heavy.pdf | file |
|
||||||
|
Then the response status code should be 404
|
||||||
@@ -51,6 +51,39 @@ Feature: /forms/pdfengines/stamp
|
|||||||
Then the response header "Content-Type" should be "application/pdf"
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
Then there should be 1 PDF(s) in the response
|
Then there should be 1 PDF(s) in the response
|
||||||
|
|
||||||
|
# Repeating the stamp fields applies several stamps in one request, in order.
|
||||||
|
# Image and pdf stamps consume the uploaded stamp files in order; text stamps
|
||||||
|
# take none. Both text stamps must land, so their content is asserted (the
|
||||||
|
# options keep them unrotated and apart so pdftotext reads them cleanly).
|
||||||
|
# See https://github.com/gotenberg/gotenberg/pull/1601.
|
||||||
|
Scenario: POST /forms/pdfengines/stamp (Multiple Stamps - pdfcpu)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| PDFENGINES_STAMP_ENGINES | pdfcpu |
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/page_1.pdf | file |
|
||||||
|
| stampSource | text | field |
|
||||||
|
| stampExpression | STAMPONE | field |
|
||||||
|
| stampOptions | {"rotation":"0","position":"tl","scale":"0.2 abs"} | field |
|
||||||
|
| stampSource | text | field |
|
||||||
|
| stampExpression | STAMPTWO | field |
|
||||||
|
| stampOptions | {"rotation":"0","position":"br","scale":"0.2 abs"} | field |
|
||||||
|
| stampSource | image | field |
|
||||||
|
| stamp | testdata/watermark.png | file |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
|
Then there should be 1 PDF(s) in the response
|
||||||
|
Then there should be the following file(s) in the response:
|
||||||
|
| foo.pdf |
|
||||||
|
Then the "foo.pdf" PDF should have the following content at page 1:
|
||||||
|
"""
|
||||||
|
STAMPONE
|
||||||
|
"""
|
||||||
|
Then the "foo.pdf" PDF should have the following content at page 1:
|
||||||
|
"""
|
||||||
|
STAMPTWO
|
||||||
|
"""
|
||||||
|
|
||||||
Scenario: POST /forms/pdfengines/stamp (PDF - pdfcpu)
|
Scenario: POST /forms/pdfengines/stamp (PDF - pdfcpu)
|
||||||
Given I have a Gotenberg container with the following environment variable(s):
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
| PDFENGINES_STAMP_ENGINES | pdfcpu |
|
| PDFENGINES_STAMP_ENGINES | pdfcpu |
|
||||||
|
|||||||
@@ -51,6 +51,39 @@ Feature: /forms/pdfengines/watermark
|
|||||||
Then the response header "Content-Type" should be "application/pdf"
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
Then there should be 1 PDF(s) in the response
|
Then there should be 1 PDF(s) in the response
|
||||||
|
|
||||||
|
# Repeating the watermark fields applies several watermarks in one request, in
|
||||||
|
# order. Image and pdf watermarks consume the uploaded watermark files in
|
||||||
|
# order; text watermarks take none. Both text watermarks must land, so their
|
||||||
|
# content is asserted (the options keep them unrotated and apart so pdftotext
|
||||||
|
# reads them cleanly).
|
||||||
|
Scenario: POST /forms/pdfengines/watermark (Multiple Watermarks - pdfcpu)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| PDFENGINES_WATERMARK_ENGINES | pdfcpu |
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/page_1.pdf | file |
|
||||||
|
| watermarkSource | text | field |
|
||||||
|
| watermarkExpression | MARKONE | field |
|
||||||
|
| watermarkOptions | {"rotation":"0","position":"tl","scale":"0.2 abs"} | field |
|
||||||
|
| watermarkSource | text | field |
|
||||||
|
| watermarkExpression | MARKTWO | field |
|
||||||
|
| watermarkOptions | {"rotation":"0","position":"br","scale":"0.2 abs"} | field |
|
||||||
|
| watermarkSource | image | field |
|
||||||
|
| watermark | testdata/watermark.png | file |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
|
Then there should be 1 PDF(s) in the response
|
||||||
|
Then there should be the following file(s) in the response:
|
||||||
|
| foo.pdf |
|
||||||
|
Then the "foo.pdf" PDF should have the following content at page 1:
|
||||||
|
"""
|
||||||
|
MARKONE
|
||||||
|
"""
|
||||||
|
Then the "foo.pdf" PDF should have the following content at page 1:
|
||||||
|
"""
|
||||||
|
MARKTWO
|
||||||
|
"""
|
||||||
|
|
||||||
Scenario: POST /forms/pdfengines/watermark (PDF - pdfcpu)
|
Scenario: POST /forms/pdfengines/watermark (PDF - pdfcpu)
|
||||||
Given I have a Gotenberg container with the following environment variable(s):
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
| PDFENGINES_WATERMARK_ENGINES | pdfcpu |
|
| PDFENGINES_WATERMARK_ENGINES | pdfcpu |
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ Feature: /
|
|||||||
When I make a "GET" request to Gotenberg at the "/" endpoint
|
When I make a "GET" request to Gotenberg at the "/" endpoint
|
||||||
Then the response status code should be 401
|
Then the response status code should be 401
|
||||||
|
|
||||||
|
# A request without a Bearer token is rejected. The JWKS URL is never fetched
|
||||||
|
# here, so no OIDC provider needs to be reachable.
|
||||||
|
Scenario: GET / (OIDC Auth)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| API_ENABLE_OIDC_AUTH | true |
|
||||||
|
| API_OIDC_ISSUER | https://gotenberg.test/ |
|
||||||
|
| API_OIDC_AUDIENCE | gotenberg |
|
||||||
|
| API_OIDC_JWKS_URL | https://gotenberg.test/jwks.json |
|
||||||
|
When I make a "GET" request to Gotenberg at the "/" endpoint
|
||||||
|
Then the response status code should be 401
|
||||||
|
|
||||||
Scenario: GET /foo/ (Root Path)
|
Scenario: GET /foo/ (Root Path)
|
||||||
Given I have a Gotenberg container with the following environment variable(s):
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
| API_ROOT_PATH | /foo/ |
|
| API_ROOT_PATH | /foo/ |
|
||||||
|
|||||||
@@ -29,14 +29,16 @@ func doRequest(method, url string, headers map[string]string, body io.Reader) (*
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func doFormDataRequest(method, url string, fields map[string]string, files map[string][]string, headers map[string]string) (*http.Response, error) {
|
func doFormDataRequest(method, url string, fields map[string][]string, files map[string][]string, headers map[string]string) (*http.Response, error) {
|
||||||
var b bytes.Buffer
|
var b bytes.Buffer
|
||||||
writer := multipart.NewWriter(&b)
|
writer := multipart.NewWriter(&b)
|
||||||
|
|
||||||
for name, value := range fields {
|
for name, values := range fields {
|
||||||
err := writer.WriteField(name, value)
|
for _, value := range values {
|
||||||
if err != nil {
|
err := writer.WriteField(name, value)
|
||||||
return nil, fmt.Errorf("write field %q: %w", name, err)
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("write field %q: %w", name, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
|
_ "image/png" // Register the PNG decoder for image.DecodeConfig.
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -206,7 +208,7 @@ func (s *scenario) iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders(ct
|
|||||||
return errors.New("no Gotenberg container")
|
return errors.New("no Gotenberg container")
|
||||||
}
|
}
|
||||||
|
|
||||||
fields := make(map[string]string)
|
fields := make(map[string][]string)
|
||||||
files := make(map[string][]string)
|
files := make(map[string][]string)
|
||||||
headers := make(map[string]string)
|
headers := make(map[string]string)
|
||||||
|
|
||||||
@@ -218,10 +220,10 @@ func (s *scenario) iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders(ct
|
|||||||
switch kind {
|
switch kind {
|
||||||
case "field":
|
case "field":
|
||||||
if name == "downloadFrom" || name == "url" || name == "cookies" {
|
if name == "downloadFrom" || name == "url" || name == "cookies" {
|
||||||
fields[name] = strings.ReplaceAll(value, "%d", fmt.Sprintf("%d", s.hostPort))
|
fields[name] = append(fields[name], strings.ReplaceAll(value, "%d", fmt.Sprintf("%d", s.hostPort)))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fields[name] = value
|
fields[name] = append(fields[name], value)
|
||||||
case "file":
|
case "file":
|
||||||
if strings.Contains(value, "teststore") {
|
if strings.Contains(value, "teststore") {
|
||||||
if s.teststoreDir == "" {
|
if s.teststoreDir == "" {
|
||||||
@@ -361,7 +363,7 @@ func (s *scenario) iMakeConcurrentRequestsToGotenberg(ctx context.Context, count
|
|||||||
return errors.New("no Gotenberg container")
|
return errors.New("no Gotenberg container")
|
||||||
}
|
}
|
||||||
|
|
||||||
fields := make(map[string]string)
|
fields := make(map[string][]string)
|
||||||
files := make(map[string][]string)
|
files := make(map[string][]string)
|
||||||
headers := make(map[string]string)
|
headers := make(map[string]string)
|
||||||
|
|
||||||
@@ -372,7 +374,7 @@ func (s *scenario) iMakeConcurrentRequestsToGotenberg(ctx context.Context, count
|
|||||||
|
|
||||||
switch kind {
|
switch kind {
|
||||||
case "field":
|
case "field":
|
||||||
fields[name] = value
|
fields[name] = append(fields[name], value)
|
||||||
case "file":
|
case "file":
|
||||||
wd, err := os.Getwd()
|
wd, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1010,6 +1012,74 @@ func (s *scenario) thePdfShouldHaveImages(ctx context.Context, name string, imag
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *scenario) theImageShouldBePixels(_ context.Context, name string, width, height int) error {
|
||||||
|
path := fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
|
||||||
|
|
||||||
|
file, err := os.Open(path) //nolint:gosec // path is built from test-controlled values.
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open image %q: %w", path, err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
config, format, err := image.DecodeConfig(file)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode image %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Width != width || config.Height != height {
|
||||||
|
return fmt.Errorf("expected %s image %dx%d, but actual is %dx%d", format, width, height, config.Width, config.Height)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *scenario) theImagePixelShouldBe(_ context.Context, name string, x, y int, want string) error {
|
||||||
|
path := fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
|
||||||
|
|
||||||
|
file, err := os.Open(path) //nolint:gosec // path is built from test-controlled values.
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open image %q: %w", path, err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
img, format, err := image.Decode(file)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode image %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r, g, b, _ := img.At(x, y).RGBA()
|
||||||
|
// RGBA returns 16-bit channels; shift down to the 8-bit hex form.
|
||||||
|
got := fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
|
||||||
|
if !strings.EqualFold(got, want) {
|
||||||
|
return fmt.Errorf("expected %s pixel at %d,%d to be %s, but actual is %s", format, x, y, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *scenario) theFileSizeShouldBe(_ context.Context, name, comparator string, sizeKB int) error {
|
||||||
|
path := fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
|
||||||
|
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stat %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := int64(sizeKB) * 1024
|
||||||
|
switch comparator {
|
||||||
|
case "less":
|
||||||
|
if info.Size() >= limit {
|
||||||
|
return fmt.Errorf("expected %s (%d bytes) to be smaller than %d KB", name, info.Size(), sizeKB)
|
||||||
|
}
|
||||||
|
case "greater":
|
||||||
|
if info.Size() <= limit {
|
||||||
|
return fmt.Errorf("expected %s (%d bytes) to be larger than %d KB", name, info.Size(), sizeKB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context, name string, kind string) error {
|
func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context, name string, kind string) error {
|
||||||
var path string
|
var path string
|
||||||
if !strings.HasPrefix(name, "*_") {
|
if !strings.HasPrefix(name, "*_") {
|
||||||
@@ -1047,7 +1117,9 @@ func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context,
|
|||||||
}
|
}
|
||||||
|
|
||||||
output = strings.ReplaceAll(output, " ", "")
|
output = strings.ReplaceAll(output, " ", "")
|
||||||
re := regexp.MustCompile(`Pagesize:(\d+)x(\d+).*`)
|
// The dimensions can be fractional (e.g. a content-sized single page),
|
||||||
|
// so match floats, not just integers.
|
||||||
|
re := regexp.MustCompile(`Pagesize:([\d.]+)x([\d.]+)`)
|
||||||
matches := re.FindStringSubmatch(output)
|
matches := re.FindStringSubmatch(output)
|
||||||
|
|
||||||
if len(matches) < 3 {
|
if len(matches) < 3 {
|
||||||
@@ -1056,22 +1128,76 @@ func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context,
|
|||||||
|
|
||||||
invert := kind == "should NOT"
|
invert := kind == "should NOT"
|
||||||
|
|
||||||
width, err := strconv.Atoi(matches[1])
|
width, err := strconv.ParseFloat(matches[1], 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert width value %q to integer: %w", matches[1], err)
|
return fmt.Errorf("convert width value %q to float: %w", matches[1], err)
|
||||||
}
|
}
|
||||||
|
|
||||||
height, err := strconv.Atoi(matches[2])
|
height, err := strconv.ParseFloat(matches[2], 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert height value %q to integer: %w", matches[2], err)
|
return fmt.Errorf("convert height value %q to float: %w", matches[2], err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if invert && height < width {
|
if invert && height < width {
|
||||||
return fmt.Errorf("expected height %d to be greater than width %d", height, width)
|
return fmt.Errorf("expected height %g to be greater than width %g", height, width)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !invert && width < height {
|
if !invert && width < height {
|
||||||
return fmt.Errorf("expected width %d to be greater than height %d", width, height)
|
return fmt.Errorf("expected width %g to be greater than height %g", width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *scenario) thePdfShouldHaveADocumentOutline(ctx context.Context, name, kind string) error {
|
||||||
|
var path string
|
||||||
|
if !strings.HasPrefix(name, "*_") {
|
||||||
|
path = fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
|
||||||
|
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("PDF %q does not exist", path)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
substr := strings.ReplaceAll(name, "*_", "")
|
||||||
|
err := filepath.Walk(s.teststoreDir, func(currentPath string, info os.FileInfo, pathErr error) error {
|
||||||
|
if pathErr != nil {
|
||||||
|
return pathErr
|
||||||
|
}
|
||||||
|
if strings.Contains(info.Name(), substr) {
|
||||||
|
path = currentPath
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("walk %q: %w", s.workdir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := []string{
|
||||||
|
"verapdf",
|
||||||
|
"-off",
|
||||||
|
"--extract",
|
||||||
|
"outlines",
|
||||||
|
filepath.Base(path),
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("exec %q: %w", cmd, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// veraPDF emits an empty features report when the catalog holds no outline.
|
||||||
|
hasOutline := !strings.Contains(output, "<featuresReport></featuresReport>")
|
||||||
|
invert := kind == "should NOT"
|
||||||
|
|
||||||
|
if !invert && !hasOutline {
|
||||||
|
return fmt.Errorf("PDF %q has no document outline", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if invert && hasOutline {
|
||||||
|
return fmt.Errorf("PDF %q has a document outline", path)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -1541,9 +1667,13 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
|
|||||||
ctx.Then(`^the (response|webhook request) PDF\(s\) should declare Factur-X XMP with conformance level "([^"]*)"$`, s.thePdfsShouldDeclareFacturXConformanceLevel)
|
ctx.Then(`^the (response|webhook request) PDF\(s\) should declare Factur-X XMP with conformance level "([^"]*)"$`, s.thePdfsShouldDeclareFacturXConformanceLevel)
|
||||||
ctx.Then(`^the "([^"]*)" PDF should have (\d+) page\(s\)$`, s.thePdfShouldHavePages)
|
ctx.Then(`^the "([^"]*)" PDF should have (\d+) page\(s\)$`, s.thePdfShouldHavePages)
|
||||||
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) be set to landscape orientation$`, s.thePdfShouldBeSetToLandscapeOrientation)
|
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) be set to landscape orientation$`, s.thePdfShouldBeSetToLandscapeOrientation)
|
||||||
|
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have a document outline$`, s.thePdfShouldHaveADocumentOutline)
|
||||||
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage)
|
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage)
|
||||||
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have content matching "([^"]*)" at page (\d+)$`, s.thePdfShouldHaveContentMatchingAtPage)
|
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have content matching "([^"]*)" at page (\d+)$`, s.thePdfShouldHaveContentMatchingAtPage)
|
||||||
ctx.Then(`^the "([^"]*)" PDF should have (\d+) image\(s\)$`, s.thePdfShouldHaveImages)
|
ctx.Then(`^the "([^"]*)" PDF should have (\d+) image\(s\)$`, s.thePdfShouldHaveImages)
|
||||||
|
ctx.Then(`^the "([^"]*)" image should be (\d+)x(\d+) pixels$`, s.theImageShouldBePixels)
|
||||||
|
ctx.Then(`^the "([^"]*)" image pixel at (\d+),(\d+) should be "([^"]*)"$`, s.theImagePixelShouldBe)
|
||||||
|
ctx.Then(`^the "([^"]*)" file size should be (less|greater) than (\d+) KB$`, s.theFileSizeShouldBe)
|
||||||
ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) {
|
ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) {
|
||||||
if s.gotenbergContainer != nil {
|
if s.gotenbergContainer != nil {
|
||||||
errTerminate := s.gotenbergContainer.Terminate(ctx, testcontainers.StopTimeout(0))
|
errTerminate := s.gotenbergContainer.Terminate(ctx, testcontainers.StopTimeout(0))
|
||||||
|
|||||||
@@ -181,6 +181,12 @@ func newServer(ctx context.Context, workdir string) (*server, error) {
|
|||||||
}
|
}
|
||||||
return c.HTML(http.StatusOK, string(b))
|
return c.HTML(http.StatusOK, string(b))
|
||||||
})
|
})
|
||||||
|
srv.GET("/redirect-to-private", func(c echo.Context) error {
|
||||||
|
s.req = c.Request()
|
||||||
|
// Redirect the browser to a non-public address so the outbound filter
|
||||||
|
// is exercised on the redirected request rather than on this URL.
|
||||||
|
return c.Redirect(http.StatusFound, "http://127.0.0.1:9999/redirected")
|
||||||
|
})
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
test/integration/testdata/image-heavy.pdf
vendored
Normal file
BIN
test/integration/testdata/image-heavy.pdf
vendored
Normal file
Binary file not shown.
18
test/integration/testdata/local-storage-html/index.html
vendored
Normal file
18
test/integration/testdata/local-storage-html/index.html
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Local storage counter</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Increments a per-origin localStorage counter and renders it, so a value
|
||||||
|
above 1 means a previous conversion of the same origin leaked into this
|
||||||
|
one. See https://github.com/gotenberg/gotenberg/issues/919. -->
|
||||||
|
<pre id="out"></pre>
|
||||||
|
<script>
|
||||||
|
var n = (parseInt(localStorage.getItem("n"), 10) || 0) + 1;
|
||||||
|
localStorage.setItem("n", n);
|
||||||
|
document.getElementById("out").textContent = "localStorageCount=" + n;
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
test/integration/testdata/optimize-image-html/image.png
vendored
Normal file
BIN
test/integration/testdata/optimize-image-html/image.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
22
test/integration/testdata/optimize-image-html/index.html
vendored
Normal file
22
test/integration/testdata/optimize-image-html/index.html
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Image-heavy page</title>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<img src="image.png" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
16
test/integration/testdata/page-outline-html/index.html
vendored
Normal file
16
test/integration/testdata/page-outline-html/index.html
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Outline</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Chapter 1</h1>
|
||||||
|
<p>Intro.</p>
|
||||||
|
<h2>Section 1.1</h2>
|
||||||
|
<p>Body.</p>
|
||||||
|
<h1>Chapter 2</h1>
|
||||||
|
<p>Intro.</p>
|
||||||
|
<h2>Section 2.1</h2>
|
||||||
|
<p>Body.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
28
test/integration/testdata/screenshot-selector-html/index.html
vendored
Normal file
28
test/integration/testdata/screenshot-selector-html/index.html
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Element screenshot</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 40px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
/* A spacer pushes the target away from the origin so the clip has to
|
||||||
|
account for the element offset, not just its size. */
|
||||||
|
.spacer {
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
#target {
|
||||||
|
width: 300px;
|
||||||
|
height: 200px;
|
||||||
|
background: #ff0000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<div id="target"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
test/integration/testdata/singlepagesheets-scrolled.xlsx
vendored
Normal file
BIN
test/integration/testdata/singlepagesheets-scrolled.xlsx
vendored
Normal file
Binary file not shown.
BIN
test/integration/testdata/slideshow.ppsm
vendored
Normal file
BIN
test/integration/testdata/slideshow.ppsm
vendored
Normal file
Binary file not shown.
BIN
test/integration/testdata/slideshow.ppsx
vendored
Normal file
BIN
test/integration/testdata/slideshow.ppsx
vendored
Normal file
Binary file not shown.
15
test/integration/testdata/ssrf-eventsource-html/index.html
vendored
Normal file
15
test/integration/testdata/ssrf-eventsource-html/index.html
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>EventSource SSRF</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>EventSource SSRF</h1>
|
||||||
|
<script type="application/javascript">
|
||||||
|
// EventSource issues an ordinary HTTP GET, so it is surfaced as a
|
||||||
|
// Fetch.requestPaused event and blocked by the outbound filter.
|
||||||
|
new EventSource("http://127.0.0.1:9999/sse");
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
15
test/integration/testdata/ssrf-fetch-html/index.html
vendored
Normal file
15
test/integration/testdata/ssrf-fetch-html/index.html
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Fetch SSRF Control</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Fetch SSRF Control</h1>
|
||||||
|
<script type="application/javascript">
|
||||||
|
// Control: an ordinary fetch to a loopback address IS surfaced as a
|
||||||
|
// Fetch.requestPaused event and blocked by CHROMIUM_DENY_PRIVATE_IPS.
|
||||||
|
fetch("http://127.0.0.1:9999/ssrf-fetch").catch(() => {});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user