mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 16:42:15 +01:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db51f9026d | ||
|
|
3187980ead | ||
|
|
eff9444294 | ||
|
|
999dd2db40 | ||
|
|
f44d59d514 | ||
|
|
092b526ab5 | ||
|
|
0b5eaff8d9 | ||
|
|
46e190970f | ||
|
|
55d19522a8 | ||
|
|
924576d3d4 | ||
|
|
405d8d1c2b | ||
|
|
1b1e100107 | ||
|
|
c3b4702424 | ||
|
|
866ae53436 | ||
|
|
53e6a0ecf8 | ||
|
|
eab3d2da12 | ||
|
|
3f01ca18d3 | ||
|
|
405f1069c0 | ||
|
|
f1d1215c7c | ||
|
|
9bf12e7cb9 | ||
|
|
b87a6d4dfb | ||
|
|
a80191a288 | ||
|
|
e4a43434dc | ||
|
|
4811a00543 | ||
|
|
cfb48d9af4 | ||
|
|
8b65315a09 | ||
|
|
20522fd1ac | ||
|
|
e28e2ebf0f | ||
|
|
15050a311b | ||
|
|
c99eb471f9 | ||
|
|
53c2f97008 | ||
|
|
61cadd0e6a | ||
|
|
813926cd47 | ||
|
|
f447e31ae2 | ||
|
|
00c6ed0387 | ||
|
|
cdf861bcfd | ||
|
|
5279d73928 | ||
|
|
e6d2a5640b | ||
|
|
4387a4f122 | ||
|
|
bf0d0a4f40 | ||
|
|
f8809b3943 | ||
|
|
9e5b05ad3c | ||
|
|
452a500955 |
@@ -1,65 +0,0 @@
|
||||
# Bruno API Collection
|
||||
|
||||
A [Bruno](https://www.usebruno.com/) collection lives in `.bruno/` and mirrors every Gotenberg route. When adding or updating a route, update the collection to match.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
.bruno/
|
||||
├── bruno.json # Collection config
|
||||
├── collection.bru # Collection-level defaults (Gotenberg-Trace header)
|
||||
├── environments/
|
||||
│ ├── Local.bru # baseUrl: http://localhost:3000
|
||||
│ └── Demo.bru # baseUrl: https://demo.gotenberg.dev
|
||||
├── Health & Info/ # GET routes
|
||||
├── Chromium/Convert/ # POST routes grouped by module
|
||||
├── Chromium/Screenshot/
|
||||
├── LibreOffice/
|
||||
└── PDF Engines/<Feature>/ # One folder per feature (Merge, Split, Rotate, …)
|
||||
```
|
||||
|
||||
## `.bru` File Format
|
||||
|
||||
```bru
|
||||
meta {
|
||||
name: <Human-readable name>
|
||||
type: http
|
||||
seq: <order within folder>
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/<path>
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/<file>)
|
||||
<mandatoryField>: <value>
|
||||
~<optionalField>: <value>
|
||||
}
|
||||
|
||||
headers {
|
||||
~Gotenberg-Output-Filename: <name>
|
||||
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
|
||||
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
|
||||
~Gotenberg-Webhook-Method: POST
|
||||
~Gotenberg-Webhook-Error-Method: POST
|
||||
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
|
||||
}
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Mandatory fields** are listed without prefix; **optional fields** are prefixed with `~` (disabled by default in Bruno).
|
||||
- **File references** use relative paths to `test/integration/testdata/`.
|
||||
- **Webhook and output filename headers** are included on every POST route as optional (`~`).
|
||||
- **One `.bru` file per request**. For routes with read/write variants (e.g., bookmarks, metadata), create separate files in the same folder.
|
||||
|
||||
## Checklist When Adding/Updating a Route
|
||||
|
||||
1. Create or update the `.bru` file in the matching folder under `.bruno/`.
|
||||
2. Include all form fields from the route handler — check `FormData*` calls in the route function.
|
||||
3. For file upload fields (`files`, `watermark`, `stamp`, `embeds`), use `@file(...)` with a suitable test file.
|
||||
4. Verify the URL path matches the route's `Path` field exactly.
|
||||
5. If you add a new module folder, keep the naming consistent (e.g., `PDF Engines/Rotate/`).
|
||||
@@ -11,7 +11,7 @@ post {
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/page-1-html/index.html)
|
||||
files: @file(../test/integration/testdata/page-1-html/index.html)
|
||||
~landscape: false
|
||||
~printBackground: false
|
||||
~scale: 1.0
|
||||
@@ -50,6 +50,9 @@ body:multipart-form {
|
||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||
~embeds: @file(../test/integration/testdata/embed_2.xml)
|
||||
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
||||
~watermarkSource: text
|
||||
~watermarkExpression: CONFIDENTIAL
|
||||
~watermarkPages:
|
||||
|
||||
@@ -51,6 +51,9 @@ body:multipart-form {
|
||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||
~embeds: @file(../test/integration/testdata/embed_2.xml)
|
||||
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
||||
~watermarkSource: text
|
||||
~watermarkExpression: CONFIDENTIAL
|
||||
~watermarkPages:
|
||||
|
||||
@@ -50,6 +50,9 @@ body:multipart-form {
|
||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||
~embeds: @file(../test/integration/testdata/embed_2.xml)
|
||||
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
||||
~watermarkSource: text
|
||||
~watermarkExpression: CONFIDENTIAL
|
||||
~watermarkPages:
|
||||
|
||||
@@ -11,7 +11,7 @@ post {
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/page_1.docx)
|
||||
files: @file(../test/integration/testdata/page_1.docx)
|
||||
~password:
|
||||
~landscape: false
|
||||
~nativePageRanges:
|
||||
@@ -67,6 +67,9 @@ body:multipart-form {
|
||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||
~embeds: @file(../test/integration/testdata/embed_2.xml)
|
||||
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
||||
~watermarkSource: text
|
||||
~watermarkExpression: CONFIDENTIAL
|
||||
~watermarkPages:
|
||||
|
||||
@@ -11,8 +11,10 @@ post {
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/page_1.pdf)
|
||||
embeds: @file(../../test/integration/testdata/page_1.pdf)
|
||||
files: @file(../test/integration/testdata/page_1.pdf)
|
||||
embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||
embeds: @file(../test/integration/testdata/embed_2.xml)
|
||||
embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
|
||||
~downloadFrom: [{"url":"https://example.com/attachment.xml","embedded":true}]
|
||||
}
|
||||
|
||||
|
||||
65
.bruno/README.md
Normal file
65
.bruno/README.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Bruno API Collection
|
||||
|
||||
[Bruno](https://www.usebruno.com/) collection mirroring every Gotenberg route. Update the collection when adding or modifying a route.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
.bruno/
|
||||
├── bruno.json # Collection config
|
||||
├── collection.bru # Collection-level defaults (Gotenberg-Trace header)
|
||||
├── environments/
|
||||
│ ├── Local.bru # baseUrl: http://localhost:3000
|
||||
│ └── Demo.bru # baseUrl: https://demo.gotenberg.dev
|
||||
├── Health & Info/ # GET routes
|
||||
├── Chromium/Convert/ # POST routes grouped by module
|
||||
├── Chromium/Screenshot/
|
||||
├── LibreOffice/
|
||||
└── PDF Engines/<Feature>/ # One folder per feature (Merge, Split, Rotate, ...)
|
||||
```
|
||||
|
||||
## `.bru` file format
|
||||
|
||||
```bru
|
||||
meta {
|
||||
name: <Human-readable name>
|
||||
type: http
|
||||
seq: <order within folder>
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/<path>
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/<file>)
|
||||
<mandatoryField>: <value>
|
||||
~<optionalField>: <value>
|
||||
}
|
||||
|
||||
headers {
|
||||
~Gotenberg-Output-Filename: <name>
|
||||
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
|
||||
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
|
||||
~Gotenberg-Webhook-Method: POST
|
||||
~Gotenberg-Webhook-Error-Method: POST
|
||||
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
|
||||
}
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- Mandatory fields have no prefix. Optional fields use `~` (disabled by default in Bruno).
|
||||
- File references use relative paths to `test/integration/testdata/`.
|
||||
- Webhook and output filename headers appear on every POST route as optional (`~`).
|
||||
- One `.bru` file per request. For routes with read/write variants (e.g., bookmarks, metadata), create separate files in the same folder.
|
||||
|
||||
## Checklist
|
||||
|
||||
1. Create or update the `.bru` file in the matching folder under `.bruno/`.
|
||||
2. Include all form fields from the route handler. Check `FormData*` calls in the route function.
|
||||
3. For file upload fields (`files`, `watermark`, `stamp`, `embeds`), use `@file(...)` with a suitable test file.
|
||||
4. Verify the URL path matches the route's `Path` field exactly.
|
||||
5. For new module folders, keep the naming consistent (e.g., `PDF Engines/Rotate/`).
|
||||
3
.env
3
.env
@@ -2,6 +2,5 @@ GOTENBERG_VERSION=snapshot
|
||||
DOCKER_REGISTRY=gotenberg
|
||||
DOCKER_REPOSITORY=gotenberg
|
||||
DOCKERFILE=build/Dockerfile
|
||||
DOCKERFILE_CLOUDRUN=build/Dockerfile.cloudrun
|
||||
DOCKERFILE_AWS_LAMBDA=build/Dockerfile.aws-lambda
|
||||
DOCKER_BUILD_CONTEXT='.'
|
||||
TARGET=gotenberg
|
||||
|
||||
32
.github/actions/build-test-push/action.yml
vendored
32
.github/actions/build-test-push/action.yml
vendored
@@ -29,14 +29,32 @@ inputs:
|
||||
|
||||
outputs:
|
||||
tags:
|
||||
description: Comma separated list of tag
|
||||
description: Comma separated list of full variant tags
|
||||
value: ${{ steps.build.outputs.tags }}
|
||||
tags_chromium:
|
||||
description: Comma separated list of Chromium variant tags
|
||||
value: ${{ steps.build.outputs.tags_chromium }}
|
||||
tags_libreoffice:
|
||||
description: Comma separated list of LibreOffice variant tags
|
||||
value: ${{ steps.build.outputs.tags_libreoffice }}
|
||||
tags_cloud_run:
|
||||
description: Comma separated list of Cloud Run tags (linux/amd64 only)
|
||||
description: Comma separated list of Cloud Run full variant tags (linux/amd64 only)
|
||||
value: ${{ steps.build.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium:
|
||||
description: Comma separated list of Cloud Run Chromium variant tags (linux/amd64 only)
|
||||
value: ${{ steps.build.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice:
|
||||
description: Comma separated list of Cloud Run LibreOffice variant tags (linux/amd64 only)
|
||||
value: ${{ steps.build.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda:
|
||||
description: Comma separated list of AWS Lambda tags (linux/amd64 and linux/arm64 only)
|
||||
description: Comma separated list of AWS Lambda full variant tags (linux/amd64 and linux/arm64 only)
|
||||
value: ${{ steps.build.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium:
|
||||
description: Comma separated list of AWS Lambda Chromium variant tags (linux/amd64 and linux/arm64 only)
|
||||
value: ${{ steps.build.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice:
|
||||
description: Comma separated list of AWS Lambda LibreOffice variant tags (linux/amd64 and linux/arm64 only)
|
||||
value: ${{ steps.build.outputs.tags_aws_lambda_libreoffice }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -91,7 +109,7 @@ runs:
|
||||
if: inputs.docker_hub_username != ''
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_TAGS: ${{ steps.build.outputs.tags }},${{ steps.build.outputs.tags_cloud_run }},${{ steps.build.outputs.tags_aws_lambda }}
|
||||
INPUT_TAGS: ${{ steps.build.outputs.tags }},${{ steps.build.outputs.tags_chromium }},${{ steps.build.outputs.tags_libreoffice }},${{ steps.build.outputs.tags_cloud_run }},${{ steps.build.outputs.tags_cloud_run_chromium }},${{ steps.build.outputs.tags_cloud_run_libreoffice }},${{ steps.build.outputs.tags_aws_lambda }},${{ steps.build.outputs.tags_aws_lambda_chromium }},${{ steps.build.outputs.tags_aws_lambda_libreoffice }}
|
||||
INPUT_DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
.github/actions/build-test-push/push.sh \
|
||||
@@ -102,5 +120,11 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
echo "tags=${{ steps.build.outputs.tags }}"
|
||||
echo "tags_chromium=${{ steps.build.outputs.tags_chromium }}"
|
||||
echo "tags_libreoffice=${{ steps.build.outputs.tags_libreoffice }}"
|
||||
echo "tags_cloud_run=${{ steps.build.outputs.tags_cloud_run }}"
|
||||
echo "tags_cloud_run_chromium=${{ steps.build.outputs.tags_cloud_run_chromium }}"
|
||||
echo "tags_cloud_run_libreoffice=${{ steps.build.outputs.tags_cloud_run_libreoffice }}"
|
||||
echo "tags_aws_lambda=${{ steps.build.outputs.tags_aws_lambda }}"
|
||||
echo "tags_aws_lambda_chromium=${{ steps.build.outputs.tags_aws_lambda_chromium }}"
|
||||
echo "tags_aws_lambda_libreoffice=${{ steps.build.outputs.tags_aws_lambda_libreoffice }}"
|
||||
|
||||
164
.github/actions/build-test-push/build.sh
vendored
164
.github/actions/build-test-push/build.sh
vendored
@@ -55,8 +55,14 @@ fi
|
||||
|
||||
# Build tags arrays.
|
||||
tags=()
|
||||
tags_chromium=()
|
||||
tags_libreoffice=()
|
||||
tags_cloud_run=()
|
||||
tags_cloud_run_chromium=()
|
||||
tags_cloud_run_libreoffice=()
|
||||
tags_aws_lambda=()
|
||||
tags_aws_lambda_chromium=()
|
||||
tags_aws_lambda_libreoffice=()
|
||||
|
||||
IFS='/' read -ra arch <<< "$platform"
|
||||
IFS='.' read -ra semver <<< "$version"
|
||||
@@ -69,55 +75,95 @@ if [ "${#semver[@]}" -eq 3 ]; then
|
||||
minor="${semver[1]}"
|
||||
patch="${semver[2]}"
|
||||
|
||||
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-${arch[1]}")
|
||||
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major-${arch[1]}")
|
||||
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor-${arch[1]}")
|
||||
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor.$patch-${arch[1]}")
|
||||
for suffix in "latest" "$major" "$major.$minor" "$major.$minor.$patch"; do
|
||||
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-${arch[1]}")
|
||||
tags_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-chromium-${arch[1]}")
|
||||
tags_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-libreoffice-${arch[1]}")
|
||||
done
|
||||
|
||||
if [ "$platform" = "linux/amd64" ]; then
|
||||
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-cloudrun")
|
||||
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major-cloudrun")
|
||||
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor-cloudrun")
|
||||
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor.$patch-cloudrun")
|
||||
for suffix in "latest" "$major" "$major.$minor" "$major.$minor.$patch"; do
|
||||
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-cloudrun")
|
||||
tags_cloud_run_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-chromium-cloudrun")
|
||||
tags_cloud_run_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-libreoffice-cloudrun")
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then
|
||||
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-aws-lambda-${arch[1]}")
|
||||
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major-aws-lambda-${arch[1]}")
|
||||
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor-aws-lambda-${arch[1]}")
|
||||
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor.$patch-aws-lambda-${arch[1]}")
|
||||
for suffix in "latest" "$major" "$major.$minor" "$major.$minor.$patch"; do
|
||||
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-aws-lambda-${arch[1]}")
|
||||
tags_aws_lambda_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-chromium-aws-lambda-${arch[1]}")
|
||||
tags_aws_lambda_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$suffix-libreoffice-aws-lambda-${arch[1]}")
|
||||
done
|
||||
fi
|
||||
else
|
||||
echo
|
||||
echo "Non-semver version detected, fallback to $version"
|
||||
|
||||
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-${arch[1]}")
|
||||
tags_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-chromium-${arch[1]}")
|
||||
tags_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-libreoffice-${arch[1]}")
|
||||
|
||||
if [ "$platform" = "linux/amd64" ]; then
|
||||
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-cloudrun")
|
||||
tags_cloud_run_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-chromium-cloudrun")
|
||||
tags_cloud_run_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-libreoffice-cloudrun")
|
||||
fi
|
||||
|
||||
if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then
|
||||
tags_aws_lambda+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-aws-lambda-${arch[1]}")
|
||||
tags_aws_lambda_chromium+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-chromium-aws-lambda-${arch[1]}")
|
||||
tags_aws_lambda_libreoffice+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-libreoffice-aws-lambda-${arch[1]}")
|
||||
fi
|
||||
fi
|
||||
|
||||
tags_flags=()
|
||||
tags_chromium_flags=()
|
||||
tags_libreoffice_flags=()
|
||||
tags_cloud_run_flags=()
|
||||
tags_cloud_run_chromium_flags=()
|
||||
tags_cloud_run_libreoffice_flags=()
|
||||
tags_aws_lambda_flags=()
|
||||
tags_aws_lambda_chromium_flags=()
|
||||
tags_aws_lambda_libreoffice_flags=()
|
||||
|
||||
echo "Will use the following tags:"
|
||||
for tag in "${tags[@]}"; do
|
||||
tags_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
for tag in "${tags_chromium[@]}"; do
|
||||
tags_chromium_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
for tag in "${tags_libreoffice[@]}"; do
|
||||
tags_libreoffice_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
for tag in "${tags_cloud_run[@]}"; do
|
||||
tags_cloud_run_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
for tag in "${tags_cloud_run_chromium[@]}"; do
|
||||
tags_cloud_run_chromium_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
for tag in "${tags_cloud_run_libreoffice[@]}"; do
|
||||
tags_cloud_run_libreoffice_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
for tag in "${tags_aws_lambda[@]}"; do
|
||||
tags_aws_lambda_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
for tag in "${tags_aws_lambda_chromium[@]}"; do
|
||||
tags_aws_lambda_chromium_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
for tag in "${tags_aws_lambda_libreoffice[@]}"; do
|
||||
tags_aws_lambda_libreoffice_flags+=("-t" "$tag")
|
||||
echo "- $tag"
|
||||
done
|
||||
echo
|
||||
|
||||
# Build images.
|
||||
@@ -145,7 +191,9 @@ join() {
|
||||
|
||||
no_arch_tag="$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version"
|
||||
|
||||
# Full variant.
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
@@ -155,30 +203,102 @@ cmd="docker buildx build \
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
|
||||
# Chromium only variant.
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg-chromium \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
${tags_chromium_flags[*]} \
|
||||
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
|
||||
# LibreOffice only variant.
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg-libreoffice \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
${tags_libreoffice_flags[*]} \
|
||||
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
|
||||
# Cloud Run variants (amd64 only).
|
||||
if [ "$platform" = "linux/amd64" ]; then
|
||||
cmd="docker build \
|
||||
--build-arg DOCKER_REGISTRY=$DOCKER_REGISTRY \
|
||||
--build-arg DOCKER_REPOSITORY=$DOCKER_REPOSITORY \
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg-cloudrun \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
${tags_cloud_run_flags[*]} \
|
||||
-f $DOCKERFILE_CLOUDRUN $DOCKER_BUILD_CONTEXT
|
||||
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg-cloudrun-chromium \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
${tags_cloud_run_chromium_flags[*]} \
|
||||
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg-cloudrun-libreoffice \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
${tags_cloud_run_libreoffice_flags[*]} \
|
||||
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
fi
|
||||
|
||||
# AWS Lambda variants (amd64 + arm64 only).
|
||||
if [ "$platform" = "linux/amd64" ] || [ "$platform" = "linux/arm64" ]; then
|
||||
cmd="docker build \
|
||||
--build-arg DOCKER_REGISTRY=$DOCKER_REGISTRY \
|
||||
--build-arg DOCKER_REPOSITORY=$DOCKER_REPOSITORY \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
${tags_aws_lambda_flags[*]} \
|
||||
-f $DOCKERFILE_AWS_LAMBDA $DOCKER_BUILD_CONTEXT
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg-aws-lambda \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
${tags_aws_lambda_flags[*]} \
|
||||
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg-aws-lambda-chromium \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
${tags_aws_lambda_chromium_flags[*]} \
|
||||
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
|
||||
cmd="docker buildx build \
|
||||
--target gotenberg-aws-lambda-libreoffice \
|
||||
--build-arg GOTENBERG_VERSION=$version \
|
||||
--platform $platform \
|
||||
--load \
|
||||
${tags_aws_lambda_libreoffice_flags[*]} \
|
||||
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
|
||||
"
|
||||
run_cmd "$cmd"
|
||||
fi
|
||||
|
||||
echo "✅ Done!"
|
||||
echo "tags=$(join "," "${tags[@]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "tags_chromium=$(join "," "${tags_chromium[@]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "tags_libreoffice=$(join "," "${tags_libreoffice[@]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "tags_cloud_run=$(join "," "${tags_cloud_run[@]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "tags_cloud_run_chromium=$(join "," "${tags_cloud_run_chromium[@]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "tags_cloud_run_libreoffice=$(join "," "${tags_cloud_run_libreoffice[@]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "tags_aws_lambda=$(join "," "${tags_aws_lambda[@]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "tags_aws_lambda_chromium=$(join "," "${tags_aws_lambda_chromium[@]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "tags_aws_lambda_libreoffice=$(join "," "${tags_aws_lambda_libreoffice[@]}")" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
|
||||
62
.github/workflows/continuous-delivery.yml
vendored
62
.github/workflows/continuous-delivery.yml
vendored
@@ -13,8 +13,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tags: ${{ steps.build_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -34,8 +40,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tags: ${{ steps.build_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -55,8 +67,14 @@ jobs:
|
||||
runs-on: ubuntu-24.04-ppc64le
|
||||
outputs:
|
||||
tags: ${{ steps.build_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -76,8 +94,14 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
outputs:
|
||||
tags: ${{ steps.build_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -97,8 +121,14 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
outputs:
|
||||
tags: ${{ steps.build_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -132,7 +162,20 @@ jobs:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_ppc64le.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }}"
|
||||
alternate_registry: thecodingmachine
|
||||
|
||||
- name: Merge Chromium
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.release_amd64.outputs.tags_chromium }},${{ needs.release_386.outputs.tags_chromium }},${{ needs.release_ppc64le.outputs.tags_chromium }},${{ needs.release_arm64.outputs.tags_chromium }},${{ needs.release_arm_v7.outputs.tags_chromium }}"
|
||||
|
||||
- name: Merge LibreOffice
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.release_amd64.outputs.tags_libreoffice }},${{ needs.release_386.outputs.tags_libreoffice }},${{ needs.release_ppc64le.outputs.tags_libreoffice }},${{ needs.release_arm64.outputs.tags_libreoffice }},${{ needs.release_arm_v7.outputs.tags_libreoffice }}"
|
||||
|
||||
- name: Merge AWS Lambda
|
||||
uses: ./.github/actions/merge
|
||||
@@ -140,11 +183,24 @@ jobs:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.release_amd64.outputs.tags_aws_lambda }},${{ needs.release_arm64.outputs.tags_aws_lambda }}"
|
||||
alternate_registry: thecodingmachine
|
||||
|
||||
- name: Merge AWS Lambda Chromium
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.release_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.release_arm64.outputs.tags_aws_lambda_chromium }}"
|
||||
|
||||
- name: Merge AWS Lambda LibreOffice
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.release_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.release_arm64.outputs.tags_aws_lambda_libreoffice }}"
|
||||
|
||||
- name: Clean
|
||||
uses: ./.github/actions/clean
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_ppc64le.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }},${{ needs.release_amd64.outputs.tags_aws_lambda }},${{ needs.release_arm64.outputs.tags_aws_lambda }}"
|
||||
tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_ppc64le.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }},${{ needs.release_amd64.outputs.tags_chromium }},${{ needs.release_386.outputs.tags_chromium }},${{ needs.release_ppc64le.outputs.tags_chromium }},${{ needs.release_arm64.outputs.tags_chromium }},${{ needs.release_arm_v7.outputs.tags_chromium }},${{ needs.release_amd64.outputs.tags_libreoffice }},${{ needs.release_386.outputs.tags_libreoffice }},${{ needs.release_ppc64le.outputs.tags_libreoffice }},${{ needs.release_arm64.outputs.tags_libreoffice }},${{ needs.release_arm_v7.outputs.tags_libreoffice }},${{ needs.release_amd64.outputs.tags_aws_lambda }},${{ needs.release_arm64.outputs.tags_aws_lambda }},${{ needs.release_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.release_arm64.outputs.tags_aws_lambda_chromium }},${{ needs.release_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.release_arm64.outputs.tags_aws_lambda_libreoffice }}"
|
||||
|
||||
122
.github/workflows/continuous-integration.yml
vendored
122
.github/workflows/continuous-integration.yml
vendored
@@ -77,8 +77,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -101,8 +107,14 @@ jobs:
|
||||
runs-on: ubuntu-24.04-ppc64le
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -125,8 +137,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -149,8 +167,14 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -173,8 +197,14 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -232,6 +262,20 @@ jobs:
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.snapshot_amd64.outputs.tags }},${{ needs.snapshot_386.outputs.tags }},${{ needs.snapshot_ppc64le.outputs.tags }},${{ needs.snapshot_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }}"
|
||||
|
||||
- name: Merge Chromium
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.snapshot_amd64.outputs.tags_chromium }},${{ needs.snapshot_386.outputs.tags_chromium }},${{ needs.snapshot_ppc64le.outputs.tags_chromium }},${{ needs.snapshot_arm64.outputs.tags_chromium }},${{ needs.snapshot_arm_v7.outputs.tags_chromium }}"
|
||||
|
||||
- name: Merge LibreOffice
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.snapshot_amd64.outputs.tags_libreoffice }},${{ needs.snapshot_386.outputs.tags_libreoffice }},${{ needs.snapshot_ppc64le.outputs.tags_libreoffice }},${{ needs.snapshot_arm64.outputs.tags_libreoffice }},${{ needs.snapshot_arm_v7.outputs.tags_libreoffice }}"
|
||||
|
||||
- name: Merge AWS Lambda
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
@@ -239,12 +283,26 @@ jobs:
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.snapshot_amd64.outputs.tags_aws_lambda }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda }}"
|
||||
|
||||
- name: Merge AWS Lambda Chromium
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.snapshot_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda_chromium }}"
|
||||
|
||||
- name: Merge AWS Lambda LibreOffice
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.snapshot_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda_libreoffice }}"
|
||||
|
||||
- name: Clean
|
||||
uses: ./.github/actions/clean
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.snapshot_amd64.outputs.tags }},${{ needs.snapshot_386.outputs.tags }},${{ needs.snapshot_ppc64le.outputs.tags }},${{ needs.snapshot_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }},${{ needs.snapshot_amd64.outputs.tags_aws_lambda }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda }}"
|
||||
tags: "${{ needs.snapshot_amd64.outputs.tags }},${{ needs.snapshot_386.outputs.tags }},${{ needs.snapshot_ppc64le.outputs.tags }},${{ needs.snapshot_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }},${{ needs.snapshot_amd64.outputs.tags_chromium }},${{ needs.snapshot_386.outputs.tags_chromium }},${{ needs.snapshot_ppc64le.outputs.tags_chromium }},${{ needs.snapshot_arm64.outputs.tags_chromium }},${{ needs.snapshot_arm_v7.outputs.tags_chromium }},${{ needs.snapshot_amd64.outputs.tags_libreoffice }},${{ needs.snapshot_386.outputs.tags_libreoffice }},${{ needs.snapshot_ppc64le.outputs.tags_libreoffice }},${{ needs.snapshot_arm64.outputs.tags_libreoffice }},${{ needs.snapshot_arm_v7.outputs.tags_libreoffice }},${{ needs.snapshot_amd64.outputs.tags_aws_lambda }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda }},${{ needs.snapshot_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda_chromium }},${{ needs.snapshot_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.snapshot_arm64.outputs.tags_aws_lambda_libreoffice }}"
|
||||
|
||||
edge_amd64:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
@@ -254,8 +312,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -277,8 +341,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -300,8 +370,14 @@ jobs:
|
||||
runs-on: ubuntu-24.04-ppc64le
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -323,8 +399,14 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -346,8 +428,14 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
outputs:
|
||||
tags: ${{ steps.build_test_push.outputs.tags }}
|
||||
tags_chromium: ${{ steps.build_test_push.outputs.tags_chromium }}
|
||||
tags_libreoffice: ${{ steps.build_test_push.outputs.tags_libreoffice }}
|
||||
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
|
||||
tags_cloud_run_chromium: ${{ steps.build_test_push.outputs.tags_cloud_run_chromium }}
|
||||
tags_cloud_run_libreoffice: ${{ steps.build_test_push.outputs.tags_cloud_run_libreoffice }}
|
||||
tags_aws_lambda: ${{ steps.build_test_push.outputs.tags_aws_lambda }}
|
||||
tags_aws_lambda_chromium: ${{ steps.build_test_push.outputs.tags_aws_lambda_chromium }}
|
||||
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v6
|
||||
@@ -380,7 +468,20 @@ jobs:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_ppc64le.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }}"
|
||||
alternate_registry: thecodingmachine
|
||||
|
||||
- name: Merge Chromium
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.edge_amd64.outputs.tags_chromium }},${{ needs.edge_386.outputs.tags_chromium }},${{ needs.edge_ppc64le.outputs.tags_chromium }},${{ needs.edge_arm64.outputs.tags_chromium }},${{ needs.edge_arm_v7.outputs.tags_chromium }}"
|
||||
|
||||
- name: Merge LibreOffice
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.edge_amd64.outputs.tags_libreoffice }},${{ needs.edge_386.outputs.tags_libreoffice }},${{ needs.edge_ppc64le.outputs.tags_libreoffice }},${{ needs.edge_arm64.outputs.tags_libreoffice }},${{ needs.edge_arm_v7.outputs.tags_libreoffice }}"
|
||||
|
||||
- name: Merge AWS Lambda
|
||||
uses: ./.github/actions/merge
|
||||
@@ -388,11 +489,24 @@ jobs:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.edge_amd64.outputs.tags_aws_lambda }},${{ needs.edge_arm64.outputs.tags_aws_lambda }}"
|
||||
alternate_registry: thecodingmachine
|
||||
|
||||
- name: Merge AWS Lambda Chromium
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.edge_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.edge_arm64.outputs.tags_aws_lambda_chromium }}"
|
||||
|
||||
- name: Merge AWS Lambda LibreOffice
|
||||
uses: ./.github/actions/merge
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.edge_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.edge_arm64.outputs.tags_aws_lambda_libreoffice }}"
|
||||
|
||||
- name: Clean
|
||||
uses: ./.github/actions/clean
|
||||
with:
|
||||
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_ppc64le.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }},${{ needs.edge_amd64.outputs.tags_aws_lambda }},${{ needs.edge_arm64.outputs.tags_aws_lambda }}"
|
||||
tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_ppc64le.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }},${{ needs.edge_amd64.outputs.tags_chromium }},${{ needs.edge_386.outputs.tags_chromium }},${{ needs.edge_ppc64le.outputs.tags_chromium }},${{ needs.edge_arm64.outputs.tags_chromium }},${{ needs.edge_arm_v7.outputs.tags_chromium }},${{ needs.edge_amd64.outputs.tags_libreoffice }},${{ needs.edge_386.outputs.tags_libreoffice }},${{ needs.edge_ppc64le.outputs.tags_libreoffice }},${{ needs.edge_arm64.outputs.tags_libreoffice }},${{ needs.edge_arm_v7.outputs.tags_libreoffice }},${{ needs.edge_amd64.outputs.tags_aws_lambda }},${{ needs.edge_arm64.outputs.tags_aws_lambda }},${{ needs.edge_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.edge_arm64.outputs.tags_aws_lambda_chromium }},${{ needs.edge_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.edge_arm64.outputs.tags_aws_lambda_libreoffice }}"
|
||||
|
||||
181
AGENTS.md
181
AGENTS.md
@@ -1,181 +0,0 @@
|
||||
# Operational Guidelines for Gotenberg
|
||||
|
||||
You are working on **Gotenberg**, a Docker-based API for converting documents to PDF. It is a widely used production dependency. Stability and backward compatibility are paramount. When in doubt about whether a change is breaking, flag it rather than assuming it's safe.
|
||||
|
||||
## Mandatory Workflow
|
||||
|
||||
Every task MUST follow these five steps in order. Do not skip any step.
|
||||
|
||||
### Step 1 — Plan
|
||||
|
||||
Before writing any code, produce a plan that covers:
|
||||
|
||||
- **Problem statement**: What needs to change and why.
|
||||
- **Proposed solution**: The recommended approach with enough detail to implement (files to modify, interface changes, pipeline positioning, form fields, etc.).
|
||||
- **Alternatives considered**: At least one alternative approach when pertinent, with a brief explanation of why the proposed solution is preferred.
|
||||
- **Scope**: List every file that will be created or modified.
|
||||
- **Testing strategy**: Which integration test tags will be affected, what new scenarios are needed, and whether unit tests are required.
|
||||
|
||||
Present the plan to the user and wait for approval before proceeding to Step 2. If the user provides a plan, validate it against the codebase and flag any issues before implementing.
|
||||
|
||||
### Step 2 — Implement
|
||||
|
||||
Implement the approved plan following the coding standards and patterns described in this document. After implementation, verify the build compiles (`go build ./...`).
|
||||
|
||||
### Step 3 — Test
|
||||
|
||||
Write or update tests based on the plan's testing strategy:
|
||||
|
||||
- **Integration tests** (primary): Gherkin scenarios in `test/integration/features/`. See [`test/integration/AGENTS.md`](test/integration/AGENTS.md) for the full reference.
|
||||
- **Unit tests** (when applicable): Table-driven tests in `*_test.go` files using mocks from `pkg/gotenberg/mocks.go`.
|
||||
|
||||
### Step 4 — Review
|
||||
|
||||
Self-review the implementation against the [Review Checklist](#review-checklist). Fix any issues found before presenting the result to the user.
|
||||
|
||||
### Step 5 — Commit
|
||||
|
||||
Present the review to the user and **wait for explicit approval**. Do NOT commit until the user confirms. Once approved, create a commit following the [Conventional Commits](https://www.conventionalcommits.org/) specification:
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
```
|
||||
|
||||
Common types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `ci`, `build`. The scope should match the module or area of the change (e.g., `chromium`, `pdfengines`, `api`).
|
||||
|
||||
Stage only the files related to the change. Do not use `git add -A` or `git add .`.
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
- **Backward compatibility is law.** Never modify existing CLI flags, environment variables, or API form fields unless explicitly instructed to perform a breaking change. Flag any breaking change immediately.
|
||||
- **Defensive programming.** Assume input is malformed. Handle errors explicitly. Never panic.
|
||||
- **Atomic commits.** One feature or fix per PR. Isolate refactoring from feature work.
|
||||
- **Idiomatic Go.** Follow "Effective Go" principles. All exported symbols must have GoDoc comments starting with their name.
|
||||
|
||||
## Project Layout
|
||||
|
||||
```
|
||||
cmd/gotenberg/ → Entry point only (wiring/startup). No business logic.
|
||||
pkg/gotenberg/ → Core module system, interfaces, utilities, mocks.
|
||||
pkg/modules/ → Feature modules (api, chromium, libreoffice, pdfengines, etc.).
|
||||
pkg/standard/ → Wires all standard modules together via imports.
|
||||
test/integration/ → Gherkin feature files + Go test infrastructure.
|
||||
build/ → Dockerfile, fonts, Chromium config.
|
||||
.bruno/ → Bruno API collection (mirrors every route).
|
||||
```
|
||||
|
||||
Key interfaces live in `pkg/gotenberg/` — `Module`, `Provisioner`, `Validator`, `Debuggable`. Every module implements `Descriptor()` and self-registers. When adding features, determine if they belong in an existing module or require a new one.
|
||||
|
||||
## Codebase Navigation
|
||||
|
||||
- Start with `pkg/gotenberg/` for core interfaces and `pkg/modules/` for feature implementations.
|
||||
- The integration test infrastructure in `test/integration/scenario/` is well-structured — read `scenario.go` and `containers.go` to understand the Gherkin step definitions before writing new tests.
|
||||
- Mocks for all major interfaces are in `pkg/gotenberg/mocks.go` — use them for unit tests rather than creating new ones.
|
||||
- Import ordering is enforced: standard library, third-party, then `github.com/gotenberg/gotenberg/v8` — separated by blank lines.
|
||||
- When making changes, run only the relevant integration test tag rather than the full suite (40min timeout).
|
||||
- Telemetry infrastructure lives in `pkg/gotenberg/telemetry.go` (global Logger, Tracer, Meter) and `pkg/gotenberg/internal/` (log handlers, OTEL SDK init). HTTP semantic conventions are in `pkg/gotenberg/semconv/`.
|
||||
|
||||
---
|
||||
|
||||
## Makefile — the Only Build Interface
|
||||
|
||||
All build and verification tasks go through the Makefile. Do not run `go` commands directly unless debugging a specific package.
|
||||
|
||||
| Command | Purpose | When to use |
|
||||
| ----------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `make build` | Build the Docker image | Before integration tests, or to verify compilation |
|
||||
| `make run` | Run Gotenberg container via `docker compose` | Manual testing. Flags are configured via Makefile variables and compose.yaml |
|
||||
| `make telemetry` | Start OpenTelemetry collector and OpenObserve | When testing telemetry locally |
|
||||
| `make down` | Stop all compose containers | After manual testing |
|
||||
| `make fmt` | Format Go code (`go fix`, `golangci-lint fmt`, `go mod tidy`) | Before every commit |
|
||||
| `make lint` | Lint Go code (strict `.golangci.yml` config) | Before every commit. Zero errors permitted |
|
||||
| `make lint-prettier` | Lint non-Go files (Markdown, YAML, etc.) with Prettier | Before every commit |
|
||||
| `make prettify` | Format non-Go files (Markdown, YAML, etc.) with Prettier | Before every commit |
|
||||
| `make test-unit` | Run unit tests (`go test -race ./...`) | After code changes to `pkg/` |
|
||||
| `make test-integration` | Run integration tests (Gherkin/Godog, 40min timeout) | After any feature or route change |
|
||||
| `make godoc` | Serve GoDoc at `localhost:6060` | To verify documentation |
|
||||
|
||||
## Module System
|
||||
|
||||
Gotenberg uses a self-registering module architecture inspired by CaddyServer. Each module:
|
||||
|
||||
- Lives in `pkg/modules/<name>/`
|
||||
- Implements the `gotenberg.Module` interface (at minimum `Descriptor()`)
|
||||
- May also implement `gotenberg.Provisioner`, `gotenberg.Validator`, or `gotenberg.Debuggable`
|
||||
- Self-registers via `init()` and is wired through `pkg/standard/`
|
||||
|
||||
When adding a feature, first determine if it belongs in an existing module. Only create a new module if the feature represents a genuinely separate concern.
|
||||
|
||||
## Coding Patterns
|
||||
|
||||
- **Error handling:** Always wrap errors with context using `fmt.Errorf("description: %w", err)`. Never swallow errors silently.
|
||||
- **Import ordering:** Enforced by `gci` — standard library, then third-party, then `github.com/gotenberg/gotenberg/v8`. Three groups separated by blank lines.
|
||||
- **Mocks:** Comprehensive mock implementations for all major interfaces live in `pkg/gotenberg/mocks.go`. Use these for unit tests.
|
||||
- **Logging:** Use `gotenberg.Logger(mod)` to get the module's slog logger during `Provision()`. All log calls must be context-aware: `logger.DebugContext(ctx, msg)`, `logger.InfoContext(ctx, msg)`, `logger.ErrorContext(ctx, msg)`. This propagates trace/span IDs into structured logs when OpenTelemetry is active.
|
||||
- **Telemetry:** External tool calls (Chromium, LibreOffice, PDF engines, webhooks, downloads) must create OTEL spans with `trace.SpanKindClient` and `semconv.ServerAddress("toolname")`. Use `gotenberg.Tracer()` and `gotenberg.Meter()` for traces and metrics respectively.
|
||||
- **No business logic in `cmd/`:** The `cmd/gotenberg/` package is strictly for wiring and startup.
|
||||
|
||||
---
|
||||
|
||||
## Review Checklist
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
- [ ] No existing CLI flags renamed or removed
|
||||
- [ ] No existing environment variables renamed or removed
|
||||
- [ ] No existing API form fields renamed or removed
|
||||
- [ ] No existing HTTP endpoints changed or removed
|
||||
- [ ] No changes to default values that alter existing behavior
|
||||
- [ ] Deprecated flags have both old and new names registered, with `fs.MarkDeprecated()`
|
||||
|
||||
If any of these are violated, the change **must** be flagged as a breaking change.
|
||||
|
||||
### Linting Standards
|
||||
|
||||
The `.golangci.yml` enforces strict rules including: `gosec`, `govet`, `errcheck`, `staticcheck`, `dupl`, `bodyclose`, `exhaustive`, `errname`, `sloglint`, `gocritic`, and more. Zero linting errors are permitted.
|
||||
|
||||
Formatters enforce `gci`, `gofmt`, `gofumpt`, `goimports` with import ordering:
|
||||
|
||||
1. Standard library
|
||||
2. Third-party packages
|
||||
3. `github.com/gotenberg/gotenberg/v8`
|
||||
|
||||
Three groups separated by blank lines.
|
||||
|
||||
### Code Quality
|
||||
|
||||
- Errors are wrapped with context: `fmt.Errorf("description: %w", err)`. No swallowed errors.
|
||||
- No business logic in `cmd/`.
|
||||
- No panics in production code paths.
|
||||
- Input is validated defensively.
|
||||
- New features belong in the correct module (or justify a new one).
|
||||
|
||||
### Documentation
|
||||
|
||||
- Every exported function, type, constant, and variable has a GoDoc comment starting with its name.
|
||||
- New packages include a `doc.go` file.
|
||||
- `README.md` is not modified unless explicitly requested.
|
||||
|
||||
### Definition of Done
|
||||
|
||||
A change is ready to merge only when:
|
||||
|
||||
1. Code compiles: `go build ./...`
|
||||
2. Code is formatted: `make fmt`
|
||||
3. All linters pass: `make lint` and `make lint-prettier`
|
||||
4. Integration tests pass: `make test-integration` (at minimum, the relevant `TAGS`)
|
||||
5. Unit tests pass: `make test-unit`
|
||||
6. All exported symbols and new packages have compliant GoDoc
|
||||
7. Bruno collection is updated (if routes were added or modified)
|
||||
|
||||
---
|
||||
|
||||
## Scoped Guidelines
|
||||
|
||||
Detailed guidelines for specific areas of the codebase live in their own `AGENTS.md` files:
|
||||
|
||||
- [`test/integration/AGENTS.md`](test/integration/AGENTS.md) — Integration test framework, Gherkin step reference, available tags, and how to write new tests.
|
||||
- [`.bruno/AGENTS.md`](.bruno/AGENTS.md) — Bruno API collection structure, `.bru` file format, conventions, and route update checklist.
|
||||
- [`pkg/modules/pdfengines/AGENTS.md`](pkg/modules/pdfengines/AGENTS.md) — How to add new PDF engine features (Makefile variable and flag).
|
||||
@@ -1,3 +0,0 @@
|
||||
# Claude Code — Gotenberg
|
||||
|
||||
Read [AGENTS.md](AGENTS.md) first. It contains everything: core principles, project layout, coding standards, the mandatory 4-step workflow (Plan → Implement → Test → Review), integration test reference, review checklist, and Bruno collection guidelines.
|
||||
198
CONTRIBUTING.md
198
CONTRIBUTING.md
@@ -1,39 +1,30 @@
|
||||
# Contributing to Gotenberg
|
||||
|
||||
Thank you for your interest in contributing to Gotenberg! This guide will help you get started.
|
||||
Gotenberg is a Docker-based API for converting documents to PDF. Two rules override everything else: **backward compatibility** (never rename or remove CLI flags, environment variables, API form fields, or HTTP endpoints without discussion) and **defensive programming** (assume input is malformed, handle errors explicitly, never panic).
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go (see version in `go.mod`)
|
||||
- Module: `github.com/gotenberg/gotenberg/v8`
|
||||
- Go: see version in `go.mod`
|
||||
- Docker
|
||||
- Node.js (see version in `.node-version`) — for Prettier linting
|
||||
- Node.js (see `.node-version`), for Prettier linting
|
||||
- [golangci-lint](https://golangci-lint.run/) v2+
|
||||
|
||||
### Build and Run
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
make build # Build the Docker image
|
||||
make run # Run a local Gotenberg container
|
||||
```
|
||||
|
||||
### Development Loop
|
||||
|
||||
```bash
|
||||
# Write your code, then:
|
||||
make build # Build the Docker image
|
||||
make run # Run a local Gotenberg container
|
||||
make fmt # Format Go code
|
||||
make prettify # Format non-Go files (Markdown, YAML, etc.)
|
||||
make lint # Lint Go code (zero errors permitted)
|
||||
make lint-prettier # Lint non-Go files
|
||||
make test-unit # Run unit tests
|
||||
make build # Build the Docker image (required before integration tests)
|
||||
make build # Required before integration tests
|
||||
make test-integration # Run all integration tests
|
||||
make telemetry # Start OpenTelemetry collector and OpenObserve
|
||||
make down # Stop all compose containers
|
||||
```
|
||||
|
||||
To run only the integration tests relevant to your change:
|
||||
Run only the integration tests relevant to your change:
|
||||
|
||||
```bash
|
||||
make test-integration TAGS=health
|
||||
@@ -41,34 +32,159 @@ make test-integration TAGS=chromium-convert-html
|
||||
make test-integration TAGS="merge,split"
|
||||
```
|
||||
|
||||
## Submitting a Pull Request
|
||||
All build and verification tasks go through the Makefile. Do not run `go` commands directly unless debugging a specific package.
|
||||
|
||||
Before opening a PR, verify:
|
||||
| Command | Purpose | When to use |
|
||||
| ---------------- | --------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `make run` | Run Gotenberg container via `docker compose` | Manual testing. Flags configured via Makefile variables and compose.yaml |
|
||||
| `make telemetry` | Start OpenTelemetry collector and OpenObserve | When testing telemetry locally |
|
||||
| `make down` | Stop all compose containers | After manual testing |
|
||||
| `make godoc` | Serve GoDoc at `localhost:6060` | To verify documentation |
|
||||
|
||||
1. Code compiles: `make build`
|
||||
2. Code is formatted: `make fmt` and `make prettify`
|
||||
3. All linters pass: `make lint` and `make lint-prettier`
|
||||
4. Integration tests pass: `make test-integration` (at minimum, the relevant tags)
|
||||
5. Unit tests pass: `make test-unit`
|
||||
6. All exported symbols and new packages have GoDoc comments
|
||||
## Project layout
|
||||
|
||||
```
|
||||
cmd/gotenberg/ -> Entry point only (wiring/startup). No business logic.
|
||||
pkg/gotenberg/ -> Core module system, interfaces, utilities, mocks.
|
||||
pkg/modules/ -> Feature modules (api, chromium, libreoffice, pdfengines, etc.).
|
||||
pkg/standard/ -> Wires all standard modules together via imports.
|
||||
test/integration/ -> Gherkin feature files + Go test infrastructure.
|
||||
build/ -> Dockerfile, fonts, Chromium config.
|
||||
.bruno/ -> Bruno API collection (mirrors every route).
|
||||
```
|
||||
|
||||
Key interfaces live in `pkg/gotenberg/`: `Module`, `Provisioner`, `Validator`, `Debuggable`. Every module implements `Descriptor()` and self-registers.
|
||||
|
||||
## Coding rules
|
||||
|
||||
### Module system
|
||||
|
||||
Gotenberg uses a self-registering module architecture inspired by CaddyServer. Each module lives in `pkg/modules/<name>/`, implements at minimum `gotenberg.Module` (`Descriptor()`), and self-registers via `init()`. Wiring happens through `pkg/standard/`.
|
||||
|
||||
Determine if a feature belongs in an existing module before creating a new one. Only create a new module for a genuinely separate concern.
|
||||
|
||||
### Error handling
|
||||
|
||||
- Wrap every error with context: `fmt.Errorf("description: %w", err)`.
|
||||
- Never swallow errors silently.
|
||||
- Match errors with `errors.Is`, never `strings.Contains`.
|
||||
- No panics in production code paths.
|
||||
- Input is validated defensively.
|
||||
|
||||
### Import ordering
|
||||
|
||||
Enforced by `gci`: standard library, then third-party, then `github.com/gotenberg/gotenberg/v8`. Three groups separated by blank lines.
|
||||
|
||||
### Logging
|
||||
|
||||
Use `gotenberg.Logger(mod)` to get the module's slog logger during `Provision()`. All log calls must be context-aware: `logger.DebugContext(ctx, msg)`, `logger.InfoContext(ctx, msg)`, `logger.ErrorContext(ctx, msg)`. This propagates trace/span IDs into structured logs when OpenTelemetry is active.
|
||||
|
||||
### Telemetry
|
||||
|
||||
External tool calls (Chromium, LibreOffice, PDF engines, webhooks, downloads) must create OTEL spans with `trace.SpanKindClient` and `semconv.ServerAddress("toolname")`. Use `gotenberg.Tracer()` and `gotenberg.Meter()` for traces and metrics.
|
||||
|
||||
### No business logic in `cmd/`
|
||||
|
||||
The `cmd/gotenberg/` package is strictly for wiring and startup.
|
||||
|
||||
### Mocks
|
||||
|
||||
Comprehensive mock implementations for all major interfaces live in `pkg/gotenberg/mocks.go`. Use these for unit tests rather than creating new ones.
|
||||
|
||||
## Documentation rules
|
||||
|
||||
### Tone
|
||||
|
||||
- Short, declarative sentences. Say what it does, then stop.
|
||||
- Lead with the action. "Validates font embedding", not "This function validates font embedding".
|
||||
- Active voice. "Gotenberg checks the profile", not "The profile is checked by Gotenberg".
|
||||
- No em dashes. Use a period, colon, or comma.
|
||||
- No "we" hedging. "Don't...", not "We do not recommend...".
|
||||
|
||||
### Godoc
|
||||
|
||||
Every exported type and function has a Godoc comment starting with its identifier name:
|
||||
|
||||
```go
|
||||
// Violation records a single rule violation with context.
|
||||
type Violation struct { ... }
|
||||
|
||||
// ValidatePDFA audits the document against a PDF/A profile.
|
||||
func ValidatePDFA(ctx context.Context, ...) ([]error, error)
|
||||
```
|
||||
|
||||
Each package should have a `doc.go` with a `// Package foo ...` comment.
|
||||
|
||||
Reference identifiers with `[Name]` brackets for pkg.go.dev linking:
|
||||
|
||||
```go
|
||||
// ValidatePDFA returns violations as []error where each element
|
||||
// is a [Violation] value. See [Rule] for the structured fields.
|
||||
```
|
||||
|
||||
### Code comments
|
||||
|
||||
- Explain _why_, not _what_.
|
||||
- No numbered step comments (`// 1. Do X`, `// 2. Do Y`).
|
||||
- No section dividers with numbers (`// --- 8. Foo ---`). Plain dividers are fine for major boundaries.
|
||||
- No noise comments that restate the code (`// Check if err is nil`, `// Return results`).
|
||||
- Reference spec clauses where relevant (`// Per ISO 32000-2, Table 116...`).
|
||||
- Mark debt with `// TODO: [context]`.
|
||||
|
||||
### Formatting non-Go files
|
||||
|
||||
Run `make prettify && make lint-prettier` for YAML, Markdown, and JSON.
|
||||
|
||||
## Testing
|
||||
|
||||
Integration tests use Gherkin (BDD) via Godog with `testcontainers-go` for Docker orchestration. Feature files live in `test/integration/features/`. Step definitions live in `test/integration/scenario/`. Read `scenario.go` and `containers.go` to understand step definitions before writing new tests.
|
||||
|
||||
Unit tests: table-driven tests in `*_test.go` files using mocks from `pkg/gotenberg/mocks.go`.
|
||||
|
||||
Run only the relevant integration test tag rather than the full suite (40min timeout).
|
||||
|
||||
See:
|
||||
|
||||
- [`test/integration/README.md`](test/integration/README.md): Gherkin step reference, available tags, writing new tests.
|
||||
- [`.bruno/README.md`](.bruno/README.md): `.bru` file format, conventions, route update checklist.
|
||||
- [`pkg/modules/pdfengines/README.md`](pkg/modules/pdfengines/README.md): adding new engine features (Makefile variable and flag).
|
||||
|
||||
## Pull requests
|
||||
|
||||
Plan non-trivial changes before coding. Open an issue or draft PR describing what needs to change, the proposed solution (files to modify, interface changes, form fields), and which integration test tags are affected.
|
||||
|
||||
### Guidelines
|
||||
|
||||
- **Conventional Commits.** Commit messages must follow the [Conventional Commits](https://www.conventionalcommits.org/) specification (e.g., `feat(chromium): add screenshot endpoint`, `fix(api): handle empty body`).
|
||||
- **One thing per PR.** Keep features, bug fixes, and refactoring in separate PRs.
|
||||
- **Backward compatibility matters.** Do not rename or remove existing CLI flags, environment variables, or API form fields without discussion.
|
||||
- **Integration tests first.** When adding a feature or route, start by writing the Gherkin scenario in `test/integration/features/`.
|
||||
- **Logging uses `gotenberg.Logger(mod)`** with context-aware calls (`*Context` variants).
|
||||
- **External operations have OTEL traces** with appropriate SpanKind and semconv attributes.
|
||||
- **No business logic in `cmd/`.** All logic belongs in `pkg/`.
|
||||
- One thing per PR. Keep features, bug fixes, and refactoring separate.
|
||||
- Backward compatibility matters. Do not rename or remove existing CLI flags, environment variables, or API form fields without discussion.
|
||||
- Integration tests first. When adding a feature or route, start by writing the Gherkin scenario.
|
||||
- Bruno collection must be updated if routes were added or modified.
|
||||
|
||||
## Detailed Guidelines
|
||||
### Checklist
|
||||
|
||||
The [`AGENTS.md`](AGENTS.md) files contain comprehensive guidelines used by both human contributors and AI-assisted tools:
|
||||
- [ ] No existing CLI flags renamed or removed
|
||||
- [ ] No existing environment variables renamed or removed
|
||||
- [ ] No existing API form fields renamed or removed
|
||||
- [ ] No existing HTTP endpoints changed or removed
|
||||
- [ ] No changes to default values that alter existing behavior
|
||||
- [ ] Deprecated flags have both old and new names registered, with `fs.MarkDeprecated()`
|
||||
- [ ] Errors wrapped with context: `fmt.Errorf("description: %w", err)`
|
||||
- [ ] No business logic in `cmd/`
|
||||
- [ ] No panics in production code paths
|
||||
- [ ] New features belong in the correct module (or justify a new one)
|
||||
- [ ] Linting: `make fmt && make lint && make prettify && make lint-prettier` passes with zero warnings
|
||||
- [ ] Every exported function, type, constant, and variable has a Godoc comment starting with its name
|
||||
- [ ] New packages include a `doc.go` file
|
||||
- [ ] Integration tests pass: `make test-integration` (at minimum, the relevant tags)
|
||||
- [ ] Unit tests pass: `make test-unit`
|
||||
- [ ] Bruno collection updated (if routes were added or modified)
|
||||
|
||||
| File | What it covers |
|
||||
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [`AGENTS.md`](AGENTS.md) | Core principles, mandatory workflow, project layout, coding patterns, module system, Makefile reference, review checklist |
|
||||
| [`test/integration/AGENTS.md`](test/integration/AGENTS.md) | Integration test framework (Godog/Gherkin), available tags, step reference, how to write new tests |
|
||||
| [`.bruno/AGENTS.md`](.bruno/AGENTS.md) | Bruno API collection structure, `.bru` file format, conventions, route update checklist |
|
||||
| [`pkg/modules/pdfengines/AGENTS.md`](pkg/modules/pdfengines/AGENTS.md) | How to add new PDF engine features (Makefile variable and flag) |
|
||||
If any backward compatibility item is violated, the change **must** be flagged as a breaking change.
|
||||
|
||||
### Commits
|
||||
|
||||
[Conventional Commits](https://www.conventionalcommits.org/): `<type>(<scope>): <description>`.
|
||||
|
||||
Common types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `ci`, `build`. The scope should match the module or area of the change (e.g., `chromium`, `pdfengines`, `api`).
|
||||
|
||||
Stage specific files. Never `git add -A` or `git add .`.
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Gemini — Gotenberg
|
||||
|
||||
Read [AGENTS.md](AGENTS.md) first. It contains everything: core principles, project layout, coding standards, the mandatory 4-step workflow (Plan → Implement → Test → Review), integration test reference, review checklist, and Bruno collection guidelines.
|
||||
13
Makefile
13
Makefile
@@ -5,8 +5,9 @@ help: ## Show the help
|
||||
@grep -hE '^[A-Za-z0-9_ \-]*?:.*##.*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.PHONY: build
|
||||
build: ## Build the Gotenberg's Docker image
|
||||
build: ## Build the Gotenberg's Docker image (use TARGET=gotenberg-chromium or TARGET=gotenberg-libreoffice for variants)
|
||||
docker build \
|
||||
--target $(TARGET) \
|
||||
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
|
||||
-f $(DOCKERFILE) $(DOCKER_BUILD_CONTEXT)
|
||||
|
||||
@@ -26,7 +27,7 @@ API_ENABLE_BASIC_AUTH=false
|
||||
GOTENBERG_API_BASIC_AUTH_USERNAME=
|
||||
GOTENBERG_API_BASIC_AUTH_PASSWORD=
|
||||
API_DOWNLOAD_FROM_ALLOW_LIST=
|
||||
API_DOWNLOAD_FROM_DENY_LIST=
|
||||
API_DOWNLOAD_FROM_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)
|
||||
API_DOWNLOAD_FROM_MAX_RETRY=4
|
||||
API_DISABLE_DOWNLOAD_FROM=false
|
||||
API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=true
|
||||
@@ -75,10 +76,10 @@ PDFENGINES_WATERMARK_ENGINES=pdfcpu,pdftk
|
||||
PDFENGINES_STAMP_ENGINES=pdfcpu,pdftk
|
||||
PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
|
||||
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
|
||||
PDFENGINES_EMBED_ENGINES=pdfcpu
|
||||
PDFENGINES_EMBED_ENGINES=qpdf,pdfcpu
|
||||
PDFENGINES_EMBED_METADATA_ENGINES=qpdf
|
||||
PROMETHEUS_NAMESPACE=gotenberg
|
||||
PROMETHEUS_COLLECT_INTERVAL=1s
|
||||
PROMETHEUS_DISABLE_ROUTE_LOGGING=false
|
||||
PROMETHEUS_DISABLE_ROUTE_TELEMETRY=true
|
||||
PROMETHEUS_DISABLE_COLLECT=false
|
||||
PROMETHEUS_METRICS_PATH=/prometheus/metrics
|
||||
@@ -91,9 +92,7 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
||||
OTEL_EXPORTER_OTLP_INSECURE=true
|
||||
WEBHOOK_ENABLE_SYNC_MODE=false
|
||||
WEBHOOK_ALLOW_LIST=
|
||||
WEBHOOK_DENY_LIST=
|
||||
WEBHOOK_ERROR_ALLOW_LIST=
|
||||
WEBHOOK_ERROR_DENY_LIST=
|
||||
WEBHOOK_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)
|
||||
WEBHOOK_MAX_RETRY=4
|
||||
WEBHOOK_RETRY_MIN_WAIT=1s
|
||||
WEBHOOK_RETRY_MAX_WAIT=30s
|
||||
|
||||
@@ -63,6 +63,7 @@ If Gotenberg powers your workflow or your business, consider [**becoming a spons
|
||||
|
||||
- [TheCodingMachine](https://thecodingmachine.com/)
|
||||
- [pdfme](https://pdfme.com/)
|
||||
- [PDFBolt](https://pdfbolt.com)
|
||||
|
||||
**Powered By**
|
||||
|
||||
|
||||
34
SECURITY.md
34
SECURITY.md
@@ -2,40 +2,28 @@
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Please ensure to keep your environment up to date and use only the latest version of Gotenberg.
|
||||
Security updates and patches will be applied only to the most recent version.
|
||||
Only the latest version receives security updates and patches. Keep your environment up to date.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Your help in identifying vulnerabilities in our project is much appreciated.
|
||||
We take all reports regarding security seriously.
|
||||
Do not publish vulnerabilities publicly. Report them through [GitHub's private vulnerability reporting](https://github.com/gotenberg/gotenberg/security/advisories/new).
|
||||
|
||||
If you discover a security vulnerability, please refrain from publishing it publicly.
|
||||
Instead, kindly send us the details via email to _neuhart [dot] julien [at] gmail [dot] com_.
|
||||
|
||||
In the subject of your email, please indicate that it's a security vulnerability report for Gotenberg.
|
||||
In your message, please include:
|
||||
Include:
|
||||
|
||||
- A detailed description of the vulnerability.
|
||||
- The steps to reproduce the issue.
|
||||
- Any potential impact of the vulnerability on the users or system.
|
||||
- Steps to reproduce the issue.
|
||||
- Potential impact on users or the system.
|
||||
|
||||
Please remember that this process is done in a _'best-effort'_ manner.
|
||||
This means we strive to respond and act as quickly as possible, but the speed may vary depending on the severity of
|
||||
the issue and our resources.
|
||||
|
||||
Thank you in advance for helping to keep our project safe!
|
||||
This process is handled on a best-effort basis. Response speed may vary depending on severity and available resources.
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
Once we have received your vulnerability report, we will work to validate and reproduce the issue.
|
||||
If we can confirm the vulnerability, we will proceed to:
|
||||
Once a report is received and confirmed:
|
||||
|
||||
- Work on a fix and a release timeline.
|
||||
- Notify you when the fix has been implemented and released.
|
||||
- Credit you for discovering the vulnerability (unless you request anonymity).
|
||||
- Please note that we will do our best to keep you informed about the progress towards resolving the issue.
|
||||
- A fix and release timeline will be prepared.
|
||||
- The reporter will be notified when the fix is released.
|
||||
- The reporter will be credited for the discovery (unless anonymity is requested).
|
||||
|
||||
## Comments on this Policy
|
||||
|
||||
If you have suggestions on how this process could be improved, please submit a pull request.
|
||||
Submit a pull request with suggestions for improving this process.
|
||||
|
||||
579
build/Dockerfile
579
build/Dockerfile
@@ -17,16 +17,16 @@ ENV CGO_ENABLED=0
|
||||
# Define the working directory outside of $GOPATH (we're using go modules).
|
||||
WORKDIR /home
|
||||
|
||||
RUN curl -Ls "https://github.com/pdfcpu/pdfcpu/archive/refs/tags/$PDFCPU_VERSION.tar.gz" -o pdfcpu.tar.gz &&\
|
||||
tar --strip-components=1 -xvzf pdfcpu.tar.gz
|
||||
RUN curl -Ls "https://github.com/pdfcpu/pdfcpu/archive/refs/tags/$PDFCPU_VERSION.tar.gz" -o pdfcpu.tar.gz \
|
||||
&& tar --strip-components=1 -xvzf pdfcpu.tar.gz
|
||||
|
||||
# Install module dependencies.
|
||||
RUN go mod download &&\
|
||||
go mod verify
|
||||
RUN go mod download \
|
||||
&& go mod verify
|
||||
|
||||
RUN go build -o pdfcpu -ldflags "-s -w -X 'main.version=$PDFCPU_VERSION' -X 'github.com/pdfcpu/pdfcpu/pkg/pdfcpu.VersionStr=$PDFCPU_VERSION' -X main.builtBy=gotenberg" ./cmd/pdfcpu &&\
|
||||
RUN go build -o pdfcpu -ldflags "-s -w -X 'main.version=$PDFCPU_VERSION' -X 'github.com/pdfcpu/pdfcpu/pkg/pdfcpu.VersionStr=$PDFCPU_VERSION' -X main.builtBy=gotenberg" ./cmd/pdfcpu \
|
||||
# Verify installation.
|
||||
./pdfcpu version
|
||||
&& ./pdfcpu version
|
||||
|
||||
# ----------------------------------------------
|
||||
# Gotenberg binary build stage
|
||||
@@ -42,14 +42,16 @@ WORKDIR /home
|
||||
# Install module dependencies.
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
RUN go mod download &&\
|
||||
go mod verify
|
||||
RUN go mod download \
|
||||
&& go mod verify
|
||||
|
||||
# Copy the source code.
|
||||
COPY cmd ./cmd
|
||||
COPY pkg ./pkg
|
||||
|
||||
RUN go build -o gotenberg -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go
|
||||
RUN go build -o gotenberg -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go \
|
||||
&& go build -o gotenberg-chromium -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg-chromium/main.go \
|
||||
&& go build -o gotenberg-libreoffice -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg-libreoffice/main.go
|
||||
|
||||
# ----------------------------------------------
|
||||
# Custom JRE stage
|
||||
@@ -57,10 +59,9 @@ RUN go build -o gotenberg -ldflags "-s -w -X 'github.com/gotenberg/gotenberg/v8/
|
||||
# ----------------------------------------------
|
||||
FROM debian:13-slim AS custom-jre-stage
|
||||
|
||||
RUN \
|
||||
apt-get update -qq &&\
|
||||
apt-get upgrade -yqq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends default-jdk-headless binutils
|
||||
RUN apt-get update -qq \
|
||||
&& apt-get upgrade -yqq \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends default-jdk-headless binutils
|
||||
|
||||
# Note: jdeps helps finding which modules a JAR requires.
|
||||
# Currently only for PDFtk, as we don't rely on LibreOffice UNO Java SDK.
|
||||
@@ -74,6 +75,25 @@ RUN jlink \
|
||||
--compress=2 \
|
||||
--output /custom-jre
|
||||
|
||||
# ----------------------------------------------
|
||||
# Downloader stage
|
||||
# ----------------------------------------------
|
||||
FROM debian:13-slim AS downloader-stage
|
||||
|
||||
# See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
|
||||
ARG PDFTK_VERSION=v3.3.3
|
||||
|
||||
RUN apt-get update -qq \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl ca-certificates
|
||||
|
||||
WORKDIR /downloads
|
||||
|
||||
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.2.0/unoconv -o unoconverter \
|
||||
&& chmod +x unoconverter
|
||||
|
||||
RUN curl -o pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" \
|
||||
&& chmod a+x pdftk-all.jar
|
||||
|
||||
# ----------------------------------------------
|
||||
# Base image stage
|
||||
# ----------------------------------------------
|
||||
@@ -82,125 +102,79 @@ FROM debian:13-slim AS base-image-stage
|
||||
ARG TIMEZONE=UTC
|
||||
ENV TZ=$TIMEZONE
|
||||
|
||||
COPY --from=custom-jre-stage /custom-jre /opt/java
|
||||
COPY --link --from=custom-jre-stage /custom-jre /opt/java
|
||||
|
||||
ENV PATH="/opt/java/bin:${PATH}"
|
||||
|
||||
# ----------------------------------------------
|
||||
# Final stage
|
||||
# Common stage
|
||||
# ----------------------------------------------
|
||||
FROM base-image-stage
|
||||
FROM base-image-stage AS common-stage
|
||||
|
||||
ARG GOTENBERG_VERSION=snapshot
|
||||
ARG GOTENBERG_USER_GID=1001
|
||||
ARG GOTENBERG_USER_UID=1001
|
||||
# See https://github.com/googlefonts/noto-emoji/releases.
|
||||
ARG NOTO_COLOR_EMOJI_VERSION=v2.051
|
||||
# See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
|
||||
ARG PDFTK_VERSION=v3.3.3
|
||||
|
||||
LABEL org.opencontainers.image.title="Gotenberg" \
|
||||
org.opencontainers.image.description="A containerized API for seamless PDF conversion." \
|
||||
org.opencontainers.image.version="$GOTENBERG_VERSION" \
|
||||
org.opencontainers.image.authors="Julien Neuhart <neuhart.julien@gmail.com>" \
|
||||
org.opencontainers.image.documentation="https://gotenberg.dev" \
|
||||
org.opencontainers.image.source="https://github.com/gotenberg/gotenberg"
|
||||
# Create a non-root user.
|
||||
# All processes in the Docker container will run with this dedicated user.
|
||||
RUN groupadd --gid "$GOTENBERG_USER_GID" gotenberg \
|
||||
&& useradd --uid "$GOTENBERG_USER_UID" --gid gotenberg --shell /bin/bash --home /home/gotenberg --no-create-home gotenberg \
|
||||
&& mkdir /home/gotenberg \
|
||||
&& chown gotenberg: /home/gotenberg
|
||||
|
||||
RUN \
|
||||
# Create a non-root user.
|
||||
# All processes in the Docker container will run with this dedicated user.
|
||||
groupadd --gid "$GOTENBERG_USER_GID" gotenberg &&\
|
||||
useradd --uid "$GOTENBERG_USER_UID" --gid gotenberg --shell /bin/bash --home /home/gotenberg --no-create-home gotenberg &&\
|
||||
mkdir /home/gotenberg &&\
|
||||
chown gotenberg: /home/gotenberg
|
||||
|
||||
RUN \
|
||||
# Install system dependencies required for the next instructions or debugging.
|
||||
# Note: tini is a helper for reaping zombie processes.
|
||||
apt-get update -qq &&\
|
||||
apt-get upgrade -yqq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl gnupg tini python3 python3-distutils-extra &&\
|
||||
# Note: tini is a helper for reaping zombie processes.
|
||||
RUN apt-get update -qq \
|
||||
&& apt-get upgrade -yqq \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
|
||||
tini \
|
||||
# Many users rely on curl for Docker health checks.
|
||||
curl \
|
||||
# Install fonts.
|
||||
# Essential metric-compatible fonts for LibreOffice layout fidelity (replaces MS Fonts).
|
||||
fonts-crosextra-carlito \
|
||||
fonts-crosextra-caladea \
|
||||
fonts-liberation \
|
||||
fonts-liberation2 \
|
||||
# Reliable general-purpose fallback for Chromium.
|
||||
fonts-dejavu \
|
||||
# Unified CJK (Chinese, Japanese, Korean) support.
|
||||
fonts-noto-cjk \
|
||||
# Standard Emoji support.
|
||||
fonts-noto-color-emoji \
|
||||
# Tofu prevention.
|
||||
fonts-noto-core \
|
||||
# Install QPDF & ExifTool (PDF engines).
|
||||
qpdf exiftool \
|
||||
# See https://github.com/nextcloud/docker/issues/380.
|
||||
&& mkdir -p /usr/share/man/man1 \
|
||||
# Cleanup.
|
||||
# Note: the Debian image does automatically a clean after each install thanks to a hook.
|
||||
# Therefore, there is no need for apt-get clean.
|
||||
# See https://stackoverflow.com/a/24417119/3248473.
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
RUN \
|
||||
# Install fonts.
|
||||
# Credits:
|
||||
# https://github.com/arachnys/athenapdf/blob/master/cli/Dockerfile.
|
||||
# https://help.accusoft.com/PrizmDoc/v12.1/HTML/Installing_Asian_Fonts_on_Ubuntu_and_Debian.html.
|
||||
curl -o ./ttf-mscorefonts-installer_3.8.1_all.deb http://httpredir.debian.org/debian/pool/contrib/m/msttcorefonts/ttf-mscorefonts-installer_3.8.1_all.deb &&\
|
||||
apt-get update -qq &&\
|
||||
apt-get upgrade -yqq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
|
||||
./ttf-mscorefonts-installer_3.8.1_all.deb \
|
||||
culmus \
|
||||
fonts-beng \
|
||||
fonts-hosny-amiri \
|
||||
fonts-lklug-sinhala \
|
||||
fonts-lohit-guru \
|
||||
fonts-lohit-knda \
|
||||
fonts-samyak-gujr \
|
||||
fonts-samyak-mlym \
|
||||
fonts-samyak-taml \
|
||||
fonts-sarai \
|
||||
fonts-sil-abyssinica \
|
||||
fonts-sil-padauk \
|
||||
fonts-telu \
|
||||
fonts-thai-tlwg \
|
||||
ttf-wqy-zenhei \
|
||||
fonts-arphic-ukai \
|
||||
fonts-arphic-uming \
|
||||
fonts-ipafont-mincho \
|
||||
fonts-ipafont-gothic \
|
||||
fonts-unfonts-core \
|
||||
# LibreOffice recommends.
|
||||
fonts-crosextra-caladea \
|
||||
fonts-crosextra-carlito \
|
||||
fonts-dejavu \
|
||||
fonts-liberation \
|
||||
fonts-liberation2 \
|
||||
fonts-linuxlibertine \
|
||||
fonts-noto-cjk \
|
||||
fonts-noto-core \
|
||||
fonts-noto-mono \
|
||||
fonts-noto-ui-core \
|
||||
fonts-sil-gentium \
|
||||
fonts-sil-gentium-basic &&\
|
||||
rm -f ./ttf-mscorefonts-installer_3.8.1_all.deb &&\
|
||||
# Add Color and Black-and-White Noto emoji font.
|
||||
# Credits:
|
||||
# https://github.com/gotenberg/gotenberg/pull/325.
|
||||
# https://github.com/googlefonts/noto-emoji.
|
||||
curl -Ls "https://github.com/googlefonts/noto-emoji/raw/$NOTO_COLOR_EMOJI_VERSION/fonts/NotoColorEmoji.ttf" -o /usr/local/share/fonts/NotoColorEmoji.ttf &&\
|
||||
# Cleanup.
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
# PDFtk bash wrapper.
|
||||
# See https://github.com/gotenberg/gotenberg/pull/273.
|
||||
RUN printf '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk \
|
||||
&& chmod +x /usr/bin/pdftk
|
||||
|
||||
RUN \
|
||||
# Install Hyphenation for LibreOffice.
|
||||
# Credits: https://wiki.archlinux.org/title/LibreOffice.
|
||||
apt-get update -qq &&\
|
||||
apt-get upgrade -yqq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
|
||||
hyphen-af hyphen-as hyphen-be hyphen-bg hyphen-bn hyphen-ca hyphen-cs hyphen-da hyphen-de hyphen-el \
|
||||
hyphen-en-gb hyphen-en-us hyphen-eo hyphen-es hyphen-fr hyphen-gl hyphen-gu hyphen-hi hyphen-hr hyphen-hu \
|
||||
hyphen-id hyphen-is hyphen-it hyphen-kn hyphen-lt hyphen-lv hyphen-ml hyphen-mn hyphen-mr hyphen-nl \
|
||||
hyphen-no hyphen-or hyphen-pa hyphen-pl hyphen-pt-br hyphen-pt-pt hyphen-ro hyphen-ru hyphen-sk hyphen-sl \
|
||||
hyphen-sr hyphen-sv hyphen-ta hyphen-te hyphen-th hyphen-uk hyphen-zu &&\
|
||||
# Cleanup.
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
# Support for arbitrary user IDs (OpenShift).
|
||||
# See:
|
||||
# https://github.com/gotenberg/gotenberg/issues/1049.
|
||||
# https://docs.redhat.com/en/documentation/openshift_container_platform/4.15/html/images/creating-images#use-uid_create-images.
|
||||
RUN usermod -aG root gotenberg \
|
||||
&& chgrp -R 0 /home/gotenberg \
|
||||
&& chmod -R g=u /home/gotenberg
|
||||
|
||||
RUN \
|
||||
# Install Chromium.
|
||||
apt-get update -qq &&\
|
||||
apt-get upgrade -yqq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium &&\
|
||||
# Verify installation.
|
||||
chromium --version &&\
|
||||
# Cleanup.
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
# COPY instructions last to maximize cache reuse when only source code changes.
|
||||
|
||||
# Improve fonts subpixel hinting and smoothing.
|
||||
# Credits:
|
||||
# https://github.com/arachnys/athenapdf/issues/69.
|
||||
# https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d.
|
||||
COPY --link build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf
|
||||
|
||||
# Copy PDFtk jar.
|
||||
COPY --link --from=downloader-stage /downloads/pdftk-all.jar /usr/bin/pdftk-all.jar
|
||||
|
||||
# Copy the pdfcpu binary (shared across all variants).
|
||||
COPY --link --from=pdfcpu-binary-stage /home/pdfcpu /usr/bin/
|
||||
|
||||
# Set default characterset encoding to UTF-8.
|
||||
# See:
|
||||
@@ -209,69 +183,7 @@ RUN \
|
||||
ENV LANG=C.UTF-8
|
||||
ENV LC_ALL=C.UTF-8
|
||||
|
||||
RUN \
|
||||
# Install LibreOffice & unoconverter. \
|
||||
echo "deb http://deb.debian.org/debian trixie-backports main" >> /etc/apt/sources.list &&\
|
||||
apt-get update -qq &&\
|
||||
apt-get upgrade -yqq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports libreoffice &&\
|
||||
curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.2.0/unoconv -o /usr/bin/unoconverter &&\
|
||||
chmod +x /usr/bin/unoconverter &&\
|
||||
# unoconverter will look for the Python binary, which has to be at version 3.
|
||||
ln -s /usr/bin/python3 /usr/bin/python &&\
|
||||
# Verify installations.
|
||||
libreoffice --version &&\
|
||||
unoconverter --version &&\
|
||||
# Cleanup.
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
RUN \
|
||||
# Install PDFtk, QPDF & ExifTool (PDF engines).
|
||||
# See https://github.com/gotenberg/gotenberg/pull/273.
|
||||
curl -o /usr/bin/pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" &&\
|
||||
chmod a+x /usr/bin/pdftk-all.jar &&\
|
||||
printf '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk && \
|
||||
chmod +x /usr/bin/pdftk &&\
|
||||
apt-get update -qq &&\
|
||||
apt-get upgrade -yqq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf exiftool &&\
|
||||
# See https://github.com/nextcloud/docker/issues/380.
|
||||
mkdir -p /usr/share/man/man1 &&\
|
||||
# Verify installations.
|
||||
pdftk --version &&\
|
||||
qpdf --version &&\
|
||||
exiftool --version &&\
|
||||
# Cleanup.
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# Support for arbitrary user IDs (OpenShift).
|
||||
# See:
|
||||
# https://github.com/gotenberg/gotenberg/issues/1049.
|
||||
# https://docs.redhat.com/en/documentation/openshift_container_platform/4.15/html/images/creating-images#use-uid_create-images.
|
||||
RUN \
|
||||
usermod -aG root gotenberg &&\
|
||||
chgrp -R 0 /home/gotenberg &&\
|
||||
chmod -R g=u /home/gotenberg
|
||||
|
||||
# Improve fonts subpixel hinting and smoothing.
|
||||
# Credits:
|
||||
# https://github.com/arachnys/athenapdf/issues/69.
|
||||
# https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d.
|
||||
COPY build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf
|
||||
|
||||
# Copy dictionnaries so that hypens work on Chromium.
|
||||
# See https://github.com/gotenberg/gotenberg/issues/1293.
|
||||
COPY --chown=gotenberg:gotenberg build/chromium-hyphen-data /opt/gotenberg/chromium-hyphen-data
|
||||
|
||||
# Copy the Golang binaries.
|
||||
COPY --from=pdfcpu-binary-stage /home/pdfcpu /usr/bin/
|
||||
COPY --from=gotenberg-binary-stage /home/gotenberg /usr/bin/
|
||||
|
||||
# Environment variables required by modules or else.
|
||||
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
|
||||
ENV CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data
|
||||
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
|
||||
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
|
||||
# Environment variables for PDF engines.
|
||||
ENV PDFTK_BIN_PATH=/usr/bin/pdftk
|
||||
ENV QPDF_BIN_PATH=/usr/bin/qpdf
|
||||
ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool
|
||||
@@ -282,6 +194,80 @@ ENV OTEL_TRACES_EXPORTER=none
|
||||
ENV OTEL_METRICS_EXPORTER=none
|
||||
ENV OTEL_LOGS_EXPORTER=none
|
||||
|
||||
# ----------------------------------------------
|
||||
# Final stage: full (Chromium + LibreOffice)
|
||||
# ----------------------------------------------
|
||||
FROM common-stage AS gotenberg
|
||||
|
||||
ARG GOTENBERG_VERSION=snapshot
|
||||
ARG GOTENBERG_USER_GID=1001
|
||||
ARG GOTENBERG_USER_UID=1001
|
||||
ARG TMP_CHROMIUM_VERSION_PPC64EL="146.0.7680.80-1~deb13u1"
|
||||
|
||||
LABEL org.opencontainers.image.title="Gotenberg" \
|
||||
org.opencontainers.image.description="A Docker-based API for converting documents to PDF." \
|
||||
org.opencontainers.image.version="$GOTENBERG_VERSION" \
|
||||
org.opencontainers.image.authors="Julien Neuhart <neuhart.julien@gmail.com>" \
|
||||
org.opencontainers.image.documentation="https://gotenberg.dev" \
|
||||
org.opencontainers.image.source="https://github.com/gotenberg/gotenberg"
|
||||
|
||||
# Install Chromium.
|
||||
# On ppc64el, the latest Chromium is broken, so we pin a known working
|
||||
# version from snapshot.debian.org via debsnap.
|
||||
RUN /bin/bash -c \
|
||||
'set -e &&\
|
||||
if [[ "$(dpkg --print-architecture)" == "ppc64el" ]]; then \
|
||||
apt-get update -qq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends devscripts &&\
|
||||
debsnap chromium-common "$TMP_CHROMIUM_VERSION_PPC64EL" -v --force --binary --architecture ppc64el &&\
|
||||
debsnap chromium "$TMP_CHROMIUM_VERSION_PPC64EL" -v --force --binary --architecture ppc64el &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install --fix-broken -y -qq --no-install-recommends "./binary-chromium-common/chromium-common_${TMP_CHROMIUM_VERSION_PPC64EL}_ppc64el.deb" "./binary-chromium/chromium_${TMP_CHROMIUM_VERSION_PPC64EL}_ppc64el.deb" &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq devscripts &&\
|
||||
rm -rf ./binary-chromium-common/* ./binary-chromium/*; \
|
||||
else \
|
||||
apt-get update -qq &&\
|
||||
apt-get upgrade -yqq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \
|
||||
fi' \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# Install LibreOffice & unoconverter.
|
||||
RUN echo "deb http://deb.debian.org/debian trixie-backports main" >> /etc/apt/sources.list \
|
||||
&& apt-get update -qq \
|
||||
&& apt-get upgrade -yqq \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
|
||||
python3 python3-distutils-extra \
|
||||
# Install Hyphenation for LibreOffice.
|
||||
# Credits: https://wiki.archlinux.org/title/LibreOffice.
|
||||
hyphen-af hyphen-as hyphen-be hyphen-bg hyphen-bn hyphen-ca hyphen-cs hyphen-da hyphen-de hyphen-el \
|
||||
hyphen-en-gb hyphen-en-us hyphen-eo hyphen-es hyphen-fr hyphen-gl hyphen-gu hyphen-hi hyphen-hr hyphen-hu \
|
||||
hyphen-id hyphen-is hyphen-it hyphen-kn hyphen-lt hyphen-lv hyphen-ml hyphen-mn hyphen-mr hyphen-nl \
|
||||
hyphen-no hyphen-or hyphen-pa hyphen-pl hyphen-pt-br hyphen-pt-pt hyphen-ro hyphen-ru hyphen-sk hyphen-sl \
|
||||
hyphen-sr hyphen-sv hyphen-ta hyphen-te hyphen-th hyphen-uk hyphen-zu \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports \
|
||||
libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw python3-uno \
|
||||
# unoconverter will look for the Python binary, which has to be at version 3.
|
||||
&& ln -s /usr/bin/python3 /usr/bin/python \
|
||||
# Cleanup.
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# COPY instructions last to maximize cache reuse.
|
||||
|
||||
# Copy the Gotenberg binary (full variant with all modules).
|
||||
COPY --link --from=gotenberg-binary-stage /home/gotenberg /usr/bin/
|
||||
|
||||
# Copy unoconverter.
|
||||
COPY --link --from=downloader-stage /downloads/unoconverter /usr/bin/unoconverter
|
||||
|
||||
# Copy dictionnaries so that hyphens work on Chromium.
|
||||
# See https://github.com/gotenberg/gotenberg/issues/1293.
|
||||
COPY --link --chown="$GOTENBERG_USER_UID:$GOTENBERG_USER_GID" build/chromium-hyphen-data /opt/gotenberg/chromium-hyphen-data
|
||||
|
||||
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
|
||||
ENV CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data
|
||||
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
|
||||
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
|
||||
|
||||
USER gotenberg
|
||||
WORKDIR /home/gotenberg
|
||||
|
||||
@@ -290,3 +276,234 @@ EXPOSE 3000
|
||||
|
||||
ENTRYPOINT [ "/usr/bin/tini", "--" ]
|
||||
CMD [ "gotenberg" ]
|
||||
|
||||
# ----------------------------------------------
|
||||
# Final stage: Chromium only
|
||||
# ----------------------------------------------
|
||||
FROM common-stage AS gotenberg-chromium
|
||||
|
||||
ARG GOTENBERG_VERSION=snapshot
|
||||
ARG GOTENBERG_USER_GID=1001
|
||||
ARG GOTENBERG_USER_UID=1001
|
||||
ARG TMP_CHROMIUM_VERSION_PPC64EL="146.0.7680.80-1~deb13u1"
|
||||
|
||||
LABEL org.opencontainers.image.title="Gotenberg (Chromium)" \
|
||||
org.opencontainers.image.description="A Docker-based API for converting documents to PDF — Chromium variant." \
|
||||
org.opencontainers.image.version="$GOTENBERG_VERSION" \
|
||||
org.opencontainers.image.authors="Julien Neuhart <neuhart.julien@gmail.com>" \
|
||||
org.opencontainers.image.documentation="https://gotenberg.dev" \
|
||||
org.opencontainers.image.source="https://github.com/gotenberg/gotenberg"
|
||||
|
||||
# Install Chromium.
|
||||
# On ppc64el, the latest Chromium is broken, so we pin a known working
|
||||
# version from snapshot.debian.org via debsnap.
|
||||
RUN /bin/bash -c \
|
||||
'set -e &&\
|
||||
if [[ "$(dpkg --print-architecture)" == "ppc64el" ]]; then \
|
||||
apt-get update -qq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends devscripts &&\
|
||||
debsnap chromium-common "$TMP_CHROMIUM_VERSION_PPC64EL" -v --force --binary --architecture ppc64el &&\
|
||||
debsnap chromium "$TMP_CHROMIUM_VERSION_PPC64EL" -v --force --binary --architecture ppc64el &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install --fix-broken -y -qq --no-install-recommends "./binary-chromium-common/chromium-common_${TMP_CHROMIUM_VERSION_PPC64EL}_ppc64el.deb" "./binary-chromium/chromium_${TMP_CHROMIUM_VERSION_PPC64EL}_ppc64el.deb" &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq devscripts &&\
|
||||
rm -rf ./binary-chromium-common/* ./binary-chromium/*; \
|
||||
else \
|
||||
apt-get update -qq &&\
|
||||
apt-get upgrade -yqq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \
|
||||
fi' \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# COPY instructions last to maximize cache reuse.
|
||||
|
||||
# Copy the Gotenberg binary (Chromium variant, no LibreOffice modules).
|
||||
COPY --link --from=gotenberg-binary-stage /home/gotenberg-chromium /usr/bin/gotenberg
|
||||
|
||||
# Copy dictionnaries so that hyphens work on Chromium.
|
||||
# See https://github.com/gotenberg/gotenberg/issues/1293.
|
||||
COPY --link --chown="$GOTENBERG_USER_UID:$GOTENBERG_USER_GID" build/chromium-hyphen-data /opt/gotenberg/chromium-hyphen-data
|
||||
|
||||
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
|
||||
ENV CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data
|
||||
# No LibreOffice in this variant; override the default to use all available engines.
|
||||
ENV PDFENGINES_CONVERT_ENGINES=
|
||||
|
||||
USER gotenberg
|
||||
WORKDIR /home/gotenberg
|
||||
|
||||
# Default API port.
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT [ "/usr/bin/tini", "--" ]
|
||||
CMD [ "gotenberg" ]
|
||||
|
||||
# ----------------------------------------------
|
||||
# Final stage: LibreOffice only
|
||||
# ----------------------------------------------
|
||||
FROM common-stage AS gotenberg-libreoffice
|
||||
|
||||
ARG GOTENBERG_VERSION=snapshot
|
||||
|
||||
LABEL org.opencontainers.image.title="Gotenberg (LibreOffice)" \
|
||||
org.opencontainers.image.description="A Docker-based API for converting documents to PDF — LibreOffice variant." \
|
||||
org.opencontainers.image.version="$GOTENBERG_VERSION" \
|
||||
org.opencontainers.image.authors="Julien Neuhart <neuhart.julien@gmail.com>" \
|
||||
org.opencontainers.image.documentation="https://gotenberg.dev" \
|
||||
org.opencontainers.image.source="https://github.com/gotenberg/gotenberg"
|
||||
|
||||
# Install LibreOffice & unoconverter.
|
||||
RUN echo "deb http://deb.debian.org/debian trixie-backports main" >> /etc/apt/sources.list \
|
||||
&& apt-get update -qq \
|
||||
&& apt-get upgrade -yqq \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
|
||||
python3 python3-distutils-extra \
|
||||
# Install Hyphenation for LibreOffice.
|
||||
# Credits: https://wiki.archlinux.org/title/LibreOffice.
|
||||
hyphen-af hyphen-as hyphen-be hyphen-bg hyphen-bn hyphen-ca hyphen-cs hyphen-da hyphen-de hyphen-el \
|
||||
hyphen-en-gb hyphen-en-us hyphen-eo hyphen-es hyphen-fr hyphen-gl hyphen-gu hyphen-hi hyphen-hr hyphen-hu \
|
||||
hyphen-id hyphen-is hyphen-it hyphen-kn hyphen-lt hyphen-lv hyphen-ml hyphen-mn hyphen-mr hyphen-nl \
|
||||
hyphen-no hyphen-or hyphen-pa hyphen-pl hyphen-pt-br hyphen-pt-pt hyphen-ro hyphen-ru hyphen-sk hyphen-sl \
|
||||
hyphen-sr hyphen-sv hyphen-ta hyphen-te hyphen-th hyphen-uk hyphen-zu \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports \
|
||||
libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw python3-uno \
|
||||
# unoconverter will look for the Python binary, which has to be at version 3.
|
||||
&& ln -s /usr/bin/python3 /usr/bin/python \
|
||||
# Cleanup.
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# COPY instructions last to maximize cache reuse.
|
||||
|
||||
# Copy the Gotenberg binary (LibreOffice variant, no Chromium modules).
|
||||
COPY --link --from=gotenberg-binary-stage /home/gotenberg-libreoffice /usr/bin/gotenberg
|
||||
|
||||
# Copy unoconverter.
|
||||
COPY --link --from=downloader-stage /downloads/unoconverter /usr/bin/unoconverter
|
||||
|
||||
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
|
||||
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
|
||||
|
||||
USER gotenberg
|
||||
WORKDIR /home/gotenberg
|
||||
|
||||
# Default API port.
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT [ "/usr/bin/tini", "--" ]
|
||||
CMD [ "gotenberg" ]
|
||||
|
||||
# ==============================================================
|
||||
# Cloud Run variants
|
||||
# ==============================================================
|
||||
|
||||
# ----------------------------------------------
|
||||
# Cloud Run: full (Chromium + LibreOffice)
|
||||
# ----------------------------------------------
|
||||
FROM gotenberg AS gotenberg-cloudrun
|
||||
|
||||
USER root
|
||||
|
||||
# For security reasons, the non-root user gotenberg does not own the Tini binary by default.
|
||||
# However, some providers like Cloud Run from Google Cloud cannot start a Docker container in that case.
|
||||
# See https://github.com/gotenberg/gotenberg/issues/90#issuecomment-543551353.
|
||||
RUN chown gotenberg: /usr/bin/tini
|
||||
|
||||
ENV API_PORT_FROM_ENV=PORT
|
||||
ENV CHROMIUM_AUTO_START=true
|
||||
ENV LIBREOFFICE_AUTO_START=true
|
||||
ENV WEBHOOK_ENABLE_SYNC_MODE=true
|
||||
ENV GOTENBERG_BUILD_DEBUG_DATA=false
|
||||
ENV LOG_STD_ENABLE_GCP_FIELDS=true
|
||||
|
||||
USER gotenberg
|
||||
|
||||
# ----------------------------------------------
|
||||
# Cloud Run: Chromium only
|
||||
# ----------------------------------------------
|
||||
FROM gotenberg-chromium AS gotenberg-cloudrun-chromium
|
||||
|
||||
USER root
|
||||
|
||||
RUN chown gotenberg: /usr/bin/tini
|
||||
|
||||
ENV API_PORT_FROM_ENV=PORT
|
||||
ENV CHROMIUM_AUTO_START=true
|
||||
ENV WEBHOOK_ENABLE_SYNC_MODE=true
|
||||
ENV GOTENBERG_BUILD_DEBUG_DATA=false
|
||||
ENV LOG_STD_ENABLE_GCP_FIELDS=true
|
||||
|
||||
USER gotenberg
|
||||
|
||||
# ----------------------------------------------
|
||||
# Cloud Run: LibreOffice only
|
||||
# ----------------------------------------------
|
||||
FROM gotenberg-libreoffice AS gotenberg-cloudrun-libreoffice
|
||||
|
||||
USER root
|
||||
|
||||
RUN chown gotenberg: /usr/bin/tini
|
||||
|
||||
ENV API_PORT_FROM_ENV=PORT
|
||||
ENV LIBREOFFICE_AUTO_START=true
|
||||
ENV WEBHOOK_ENABLE_SYNC_MODE=true
|
||||
ENV GOTENBERG_BUILD_DEBUG_DATA=false
|
||||
ENV LOG_STD_ENABLE_GCP_FIELDS=true
|
||||
|
||||
USER gotenberg
|
||||
|
||||
# ==============================================================
|
||||
# AWS Lambda variants
|
||||
# ==============================================================
|
||||
|
||||
# ----------------------------------------------
|
||||
# AWS Lambda: full (Chromium + LibreOffice)
|
||||
# ----------------------------------------------
|
||||
FROM gotenberg AS gotenberg-aws-lambda
|
||||
|
||||
USER root
|
||||
|
||||
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
|
||||
|
||||
ENV AWS_LWA_PORT=3000
|
||||
ENV AWS_LWA_READINESS_CHECK_PATH=/health
|
||||
ENV AWS_LWA_INVOKE_MODE=buffered
|
||||
ENV API_PORT_FROM_ENV=AWS_LWA_PORT
|
||||
ENV WEBHOOK_ENABLE_SYNC_MODE=true
|
||||
ENV GOTENBERG_BUILD_DEBUG_DATA=false
|
||||
|
||||
USER gotenberg
|
||||
|
||||
# ----------------------------------------------
|
||||
# AWS Lambda: Chromium only
|
||||
# ----------------------------------------------
|
||||
FROM gotenberg-chromium AS gotenberg-aws-lambda-chromium
|
||||
|
||||
USER root
|
||||
|
||||
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
|
||||
|
||||
ENV AWS_LWA_PORT=3000
|
||||
ENV AWS_LWA_READINESS_CHECK_PATH=/health
|
||||
ENV AWS_LWA_INVOKE_MODE=buffered
|
||||
ENV API_PORT_FROM_ENV=AWS_LWA_PORT
|
||||
ENV WEBHOOK_ENABLE_SYNC_MODE=true
|
||||
ENV GOTENBERG_BUILD_DEBUG_DATA=false
|
||||
|
||||
USER gotenberg
|
||||
|
||||
# ----------------------------------------------
|
||||
# AWS Lambda: LibreOffice only
|
||||
# ----------------------------------------------
|
||||
FROM gotenberg-libreoffice AS gotenberg-aws-lambda-libreoffice
|
||||
|
||||
USER root
|
||||
|
||||
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
|
||||
|
||||
ENV AWS_LWA_PORT=3000
|
||||
ENV AWS_LWA_READINESS_CHECK_PATH=/health
|
||||
ENV AWS_LWA_INVOKE_MODE=buffered
|
||||
ENV API_PORT_FROM_ENV=AWS_LWA_PORT
|
||||
ENV WEBHOOK_ENABLE_SYNC_MODE=true
|
||||
ENV GOTENBERG_BUILD_DEBUG_DATA=false
|
||||
|
||||
USER gotenberg
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
ARG DOCKER_REGISTRY
|
||||
ARG DOCKER_REPOSITORY
|
||||
ARG GOTENBERG_VERSION
|
||||
|
||||
FROM $DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION
|
||||
|
||||
USER root
|
||||
|
||||
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
|
||||
|
||||
# AWS.
|
||||
ENV AWS_LWA_PORT=3000
|
||||
ENV AWS_LWA_READINESS_CHECK_PATH=/health
|
||||
ENV AWS_LWA_INVOKE_MODE=buffered
|
||||
|
||||
# Gotenberg.
|
||||
ENV API_PORT_FROM_ENV=AWS_LWA_PORT
|
||||
ENV WEBHOOK_ENABLE_SYNC_MODE=true
|
||||
ENV GOTENBERG_BUILD_DEBUG_DATA=false
|
||||
|
||||
USER gotenberg
|
||||
@@ -1,22 +0,0 @@
|
||||
ARG DOCKER_REGISTRY
|
||||
ARG DOCKER_REPOSITORY
|
||||
ARG GOTENBERG_VERSION
|
||||
|
||||
FROM $DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION
|
||||
|
||||
USER root
|
||||
|
||||
# For security reasons, the non-root user gotenberg does not own the Tini binary by default.
|
||||
# However, some providers like Cloud Run from Google Cloud cannot start a Docker container in that case.
|
||||
# See https://github.com/gotenberg/gotenberg/issues/90#issuecomment-543551353.
|
||||
RUN chown gotenberg: /usr/bin/tini
|
||||
|
||||
# Gotenberg.
|
||||
ENV API_PORT_FROM_ENV=PORT
|
||||
ENV CHROMIUM_AUTO_START=true
|
||||
ENV LIBREOFFICE_AUTO_START=true
|
||||
ENV WEBHOOK_ENABLE_SYNC_MODE=true
|
||||
ENV GOTENBERG_BUILD_DEBUG_DATA=false
|
||||
ENV LOG_ENABLE_GCP_FIELDS=true
|
||||
|
||||
USER gotenberg
|
||||
11
cmd/gotenberg-chromium/main.go
Normal file
11
cmd/gotenberg-chromium/main.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
gotenbergcmd "github.com/gotenberg/gotenberg/v8/cmd"
|
||||
// Gotenberg modules (Chromium variant — no LibreOffice).
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/standard/chromium"
|
||||
)
|
||||
|
||||
func main() {
|
||||
gotenbergcmd.Run()
|
||||
}
|
||||
11
cmd/gotenberg-libreoffice/main.go
Normal file
11
cmd/gotenberg-libreoffice/main.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
gotenbergcmd "github.com/gotenberg/gotenberg/v8/cmd"
|
||||
// Gotenberg modules (LibreOffice variant — no Chromium).
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/standard/libreoffice"
|
||||
)
|
||||
|
||||
func main() {
|
||||
gotenbergcmd.Run()
|
||||
}
|
||||
@@ -77,18 +77,16 @@ services:
|
||||
- "--pdfengines-encrypt-engines=${PDFENGINES_ENCRYPT_ENGINES}"
|
||||
- "--pdfengines-rotate-engines=${PDFENGINES_ROTATE_ENGINES}"
|
||||
- "--pdfengines-embed-engines=${PDFENGINES_EMBED_ENGINES}"
|
||||
- "--pdfengines-embed-metadata-engines=${PDFENGINES_EMBED_METADATA_ENGINES}"
|
||||
- "--pdfengines-disable-routes=${PDFENGINES_DISABLE_ROUTES}"
|
||||
- "--prometheus-namespace=${PROMETHEUS_NAMESPACE}"
|
||||
- "--prometheus-collect-interval=${PROMETHEUS_COLLECT_INTERVAL}"
|
||||
- "--prometheus-disable-route-logging=${PROMETHEUS_DISABLE_ROUTE_LOGGING}"
|
||||
- "--prometheus-disable-route-telemetry=${PROMETHEUS_DISABLE_ROUTE_TELEMETRY}"
|
||||
- "--prometheus-disable-collect=${PROMETHEUS_DISABLE_COLLECT}"
|
||||
- "--prometheus-metrics-path=${PROMETHEUS_METRICS_PATH}"
|
||||
- "--webhook-enable-sync-mode=${WEBHOOK_ENABLE_SYNC_MODE}"
|
||||
- "--webhook-allow-list=${WEBHOOK_ALLOW_LIST}"
|
||||
- "--webhook-deny-list=${WEBHOOK_DENY_LIST}"
|
||||
- "--webhook-error-allow-list=${WEBHOOK_ERROR_ALLOW_LIST}"
|
||||
- "--webhook-error-deny-list=${WEBHOOK_ERROR_DENY_LIST}"
|
||||
- "--webhook-max-retry=${WEBHOOK_MAX_RETRY}"
|
||||
- "--webhook-retry-min-wait=${WEBHOOK_RETRY_MIN_WAIT}"
|
||||
- "--webhook-retry-max-wait=${WEBHOOK_RETRY_MAX_WAIT}"
|
||||
|
||||
81
go.mod
81
go.mod
@@ -5,37 +5,36 @@ go 1.26.0
|
||||
require (
|
||||
github.com/alexliesenfeld/health v0.8.1
|
||||
github.com/barasher/go-exiftool v1.10.0
|
||||
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc
|
||||
github.com/chromedp/cdproto v0.0.0-20260405000525-47a8ff65b46a
|
||||
github.com/chromedp/chromedp v0.15.1
|
||||
github.com/cucumber/godog v0.15.1
|
||||
github.com/dlclark/regexp2 v1.11.5
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
github.com/docker/go-connections v0.6.0
|
||||
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
|
||||
github.com/gomarkdown/markdown v0.0.0-20260412113850-134a5b2cce7f
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8
|
||||
github.com/labstack/echo/v4 v4.15.1
|
||||
github.com/labstack/gommon v0.4.2
|
||||
github.com/mholt/archives v0.1.5
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/moby/moby/api v1.54.1
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/shirou/gopsutil/v4 v4.26.2
|
||||
github.com/shirou/gopsutil/v4 v4.26.3
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/testcontainers/testcontainers-go v0.41.0
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0
|
||||
go.opentelemetry.io/otel v1.42.0
|
||||
go.opentelemetry.io/otel/log v0.18.0
|
||||
go.opentelemetry.io/otel/metric v1.42.0
|
||||
go.opentelemetry.io/otel/sdk v1.42.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.18.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0
|
||||
go.opentelemetry.io/otel/trace v1.42.0
|
||||
golang.org/x/net v0.52.0
|
||||
github.com/testcontainers/testcontainers-go v0.42.0
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/log v0.19.0
|
||||
go.opentelemetry.io/otel/metric v1.43.0
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.19.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/term v0.41.0
|
||||
golang.org/x/text v0.35.0
|
||||
golang.org/x/term v0.42.0
|
||||
golang.org/x/text v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -43,7 +42,7 @@ require (
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/STARRY-S/zip v0.2.3 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bodgit/plumbing v1.3.0 // indirect
|
||||
@@ -62,6 +61,7 @@ require (
|
||||
github.com/cucumber/messages/go/v21 v21.0.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
@@ -83,26 +83,25 @@ require (
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
||||
github.com/minio/minlz v1.1.0 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.2.0 // indirect
|
||||
github.com/moby/moby/client v0.4.0 // indirect
|
||||
github.com/moby/patternmatcher v0.6.1 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/morikuni/aec v1.1.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nwaples/rardecode/v2 v2.2.2 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.26 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
@@ -119,28 +118,28 @@ require (
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 // indirect
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
178
go.sum
178
go.sum
@@ -10,8 +10,8 @@ github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
|
||||
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
|
||||
github.com/alexliesenfeld/health v0.8.1 h1:wdE3vt+cbJotiR8DGDBZPKHDFoJbAoWEfQTcqrmedUg=
|
||||
github.com/alexliesenfeld/health v0.8.1/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEXNMpIhs=
|
||||
@@ -30,8 +30,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=
|
||||
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
|
||||
github.com/chromedp/cdproto v0.0.0-20260405000525-47a8ff65b46a h1:Kk4P1W58eAf+OUGtx51cM7CcJokJuBEmOxxwPdHFH4Q=
|
||||
github.com/chromedp/cdproto v0.0.0-20260405000525-47a8ff65b46a/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
|
||||
github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
|
||||
github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4=
|
||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||
@@ -47,8 +47,8 @@ github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7np
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
||||
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI=
|
||||
github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0=
|
||||
github.com/cucumber/godog v0.15.1 h1:rb/6oHDdvVZKS66hrhpjFQFHjthFSrQBCOI1LwshNTI=
|
||||
@@ -63,8 +63,6 @@ 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/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
|
||||
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
@@ -100,8 +98,8 @@ github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1
|
||||
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
|
||||
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/gomarkdown/markdown v0.0.0-20260412113850-134a5b2cce7f h1:C5vKBogs/Qf5ID8F8XuRO8SFL+5SH7JMJrAfdLAZ2iA=
|
||||
github.com/gomarkdown/markdown v0.0.0-20260412113850-134a5b2cce7f/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
@@ -154,14 +152,14 @@ github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 h1:Qj3hTcdWH8uMZDI41HNuTuJN525C7NBrbtH5kSO6fPk=
|
||||
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
|
||||
github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
@@ -174,10 +172,12 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
||||
github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
|
||||
github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
|
||||
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
|
||||
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
@@ -186,8 +186,6 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
|
||||
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nwaples/rardecode/v2 v2.2.2 h1:/5oL8dzYivRM/tqX9VcTSWfbpwcbwKG1QtSJr3b3KcU=
|
||||
@@ -200,8 +198,6 @@ github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhA
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
|
||||
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
@@ -219,8 +215,8 @@ github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4Ul
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI=
|
||||
github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
|
||||
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
|
||||
@@ -235,8 +231,8 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
@@ -244,8 +240,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/testcontainers/testcontainers-go v0.41.0 h1:mfpsD0D36YgkxGj2LrIyxuwQ9i2wCKAD+ESsYM1wais=
|
||||
github.com/testcontainers/testcontainers-go v0.41.0/go.mod h1:pdFrEIfaPl24zmBjerWTTYaY0M6UHsqA1YSvsoU40MI=
|
||||
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
|
||||
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
|
||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
@@ -263,52 +259,52 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0 h1:NFIS6x7wyObQ7cR84x7bt1sr8nYBx89s3x3GwRjw40k=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0/go.mod h1:39SaByOyDMRMe872AE7uelMuQZidIw7LLFAnQi0FWTE=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 h1:dkBzNEAIKADEaFnuESzcXvpd09vxvDZsOjx11gjUqLk=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0/go.mod h1:Z5RIwRkZgauOIfnG5IpidvLpERjhTninpP1dTG2jTl4=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 h1:4fnRcNpc6YFtG3zsFw9achKn3XgmxPxuMuqIL5rE8e8=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0/go.mod h1:qTvIHMFKoxW7HXg02gm6/Wofhq5p3Ib/A/NNt1EoBSQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
|
||||
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 h1:deI9UQMoGFgrg5iLPgzueqFPHevDl+28YKfSpPTI6rY=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0/go.mod h1:PFx9NgpNUKXdf7J4Q3agRxMs3Y07QhTCVipKmLsMKnU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 h1:icqq3Z34UrEFk2u+HMhTtRsvo7Ues+eiJVjaJt62njs=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0/go.mod h1:W2m8P+d5Wn5kipj4/xmbt9uMqezEKfBjzVJadfABSBE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 h1:H7O6RlGOMTizyl3R08Kn5pdM06bnH8oscSj7o11tmLA=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0/go.mod h1:mBFWu/WOVDkWWsR7Tx7h6EpQB8wsv7P0Yrh0Pb7othc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 h1:KJVjPD3rcPb98rIs3HznyJlrfx9ge5oJvxxlGR+P/7s=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0/go.mod h1:K3kRa2ckmHWQaTWQdPRHc7qGXASuVuoEQXzrvlA98Ws=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs=
|
||||
go.opentelemetry.io/otel/log v0.18.0 h1:XgeQIIBjZZrliksMEbcwMZefoOSMI1hdjiLEiiB0bAg=
|
||||
go.opentelemetry.io/otel/log v0.18.0/go.mod h1:KEV1kad0NofR3ycsiDH4Yjcoj0+8206I6Ox2QYFSNgI=
|
||||
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
|
||||
go.opentelemetry.io/otel/sdk/log v0.18.0 h1:n8OyZr7t7otkeTnPTbDNom6rW16TBYGtvyy2Gk6buQw=
|
||||
go.opentelemetry.io/otel/sdk/log v0.18.0/go.mod h1:C0+wxkTwKpOCZLrlJ3pewPiiQwpzycPI/u6W0Z9fuYk=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0 h1:l3mYuPsuBx6UKE47BVcPrZoZ0q/KER57vbj2qkgDLXA=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0/go.mod h1:7cHtiVJpZebB3wybTa4NG+FUo5NPe3PROz1FqB0+qdw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
|
||||
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0 h1:hhPGP3zvvy1xWT9RTy970wlniSxFttBIsAK1gvMguJM=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0/go.mod h1:twJF7inoMza6kxMcF8JOdL3mPmtOZu7GEr34CUNE6Dg=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 h1:w3zlHYETbDwXyWHZlyyR58ZC39XGi8rAhkBgUgJ9d5w=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0/go.mod h1:GR/mClR2nn7vE8RLwxKjoBNg+QtgdDhRzxVa93koy5o=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0 h1:0D3GFvELGIwQGfC6agLsbrEYSGWZTRTxIXxcQUqrOuk=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0/go.mod h1:DM2NV7Zb8CcGeVPt6glouY0FAiwZQ/iqgcWExhgWeN8=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 h1:Dn8rkudDzY6KV9dr/D/bTUuWgqDf9xe0rr4G2elrn0Y=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0/go.mod h1:gMk9F0xDgyN9M/3Ed5Y1wKcx/9mlU91NXY2SNq7RQuU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0 h1:GJkybS+crDMdExT/BUNCEgfrmfboztcS6PhvSo88HKM=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0/go.mod h1:NuAyxRYIG2lKX3YQkB+83StTxM7s52PUUkRRiC0wnYI=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
|
||||
go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4=
|
||||
go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko=
|
||||
go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
@@ -317,10 +313,10 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
|
||||
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -328,23 +324,23 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d h1:/aDRtSZJjyLQzm75d+a1wOJaqyKBMvIAfeQmoa3ORiI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:etfGUgejTiadZAUaEP14NP97xi1RGeawqkjDARA/UOs=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -355,3 +351,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||
|
||||
22
package-lock.json
generated
22
package-lock.json
generated
@@ -5,7 +5,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"prettier": "3.8.1",
|
||||
"prettier": "3.8.2",
|
||||
"prettier-plugin-gherkin": "^3.1.3",
|
||||
"prettier-plugin-sh": "^0.18.0"
|
||||
}
|
||||
@@ -34,9 +34,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@reteps/dockerfmt": {
|
||||
"version": "0.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.3.6.tgz",
|
||||
"integrity": "sha512-Tb5wIMvBf/nLejTQ61krK644/CEMB/cpiaIFXqGApfGqO3GwcR3qnI0DbmkFVCl2OyEp8LnLX3EkucoL0+tbFg==",
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.5.2.tgz",
|
||||
"integrity": "sha512-Hbr7yen4fP5TxGM54ucXa4o5NwWXatJ6Bd9I8gp0PValYbI4Rug2Gu+rVv7K7o/efQc3F5ctqWJz47rYaa8zBw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -58,9 +58,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||
"version": "3.8.2",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz",
|
||||
"integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -86,13 +86,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier-plugin-sh": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.18.0.tgz",
|
||||
"integrity": "sha512-cW1XL27FOJQ/qGHOW6IHwdCiNWQsAgK+feA8V6+xUTaH0cD3Mh+tFAtBvEEWvuY6hTDzRV943Fzeii+qMOh7nQ==",
|
||||
"version": "0.18.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.18.1.tgz",
|
||||
"integrity": "sha512-uZmU22wBMevjh3rmCatNQqiEer2+5KLa0xYCBX6zQQUQkcNzVL+s6FbPKK6ZSUNUbQk6jMAcQHrYPvuL2W6ihQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reteps/dockerfmt": "^0.3.6",
|
||||
"@reteps/dockerfmt": "^0.5.1",
|
||||
"sh-syntax": "^0.5.8"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"prettier": "3.8.1",
|
||||
"prettier": "3.8.2",
|
||||
"prettier-plugin-gherkin": "^3.1.3",
|
||||
"prettier-plugin-sh": "^0.18.0"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package gotenberg provides most of the logic of the module system.
|
||||
// Package gotenberg implements the core module system.
|
||||
//
|
||||
// caddyserver/caddy, licensed under the Apache License 2.0, has significantly
|
||||
// inspired this module system.
|
||||
// Significantly inspired by caddyserver/caddy, licensed under the Apache
|
||||
// License 2.0.
|
||||
//
|
||||
// More details are available on https://caddyserver.com/.
|
||||
// See https://caddyserver.com/.
|
||||
package gotenberg
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// Package otel gathers initialization utilities for OpenTelemetry
|
||||
// instrumentation.
|
||||
//
|
||||
// This package has been significantly inspired by
|
||||
// https://github.com/lucavallin/gotel.
|
||||
// Significantly inspired by https://github.com/lucavallin/gotel.
|
||||
//
|
||||
// See: https://opentelemetry.io/.
|
||||
// See https://opentelemetry.io/.
|
||||
package otel
|
||||
|
||||
@@ -45,20 +45,21 @@ func (mod *DebuggableMock) Debug() map[string]any {
|
||||
//
|
||||
//nolint:dupl
|
||||
type PdfEngineMock struct {
|
||||
MergeMock func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
|
||||
SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
|
||||
FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath string) error
|
||||
ConvertMock func(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
|
||||
ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
|
||||
PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
|
||||
WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
|
||||
ReadBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error)
|
||||
EncryptMock func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
|
||||
EmbedFilesMock func(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
|
||||
WriteBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
|
||||
WatermarkMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
|
||||
StampMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
|
||||
RotateMock func(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
|
||||
MergeMock func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
|
||||
SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
|
||||
FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath string) error
|
||||
ConvertMock func(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
|
||||
ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
|
||||
PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
|
||||
WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
|
||||
ReadBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error)
|
||||
EncryptMock func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
|
||||
EmbedFilesMock func(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
|
||||
EmbedFilesMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error
|
||||
WriteBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
|
||||
WatermarkMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
|
||||
StampMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
|
||||
RotateMock func(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
||||
@@ -101,6 +102,10 @@ func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *slog.Logger
|
||||
return engine.EmbedFilesMock(ctx, logger, filePaths, inputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
|
||||
return engine.EmbedFilesMetadataMock(ctx, logger, metadata, inputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error {
|
||||
return engine.WriteBookmarksMock(ctx, logger, inputPath, bookmarks)
|
||||
}
|
||||
|
||||
325
pkg/gotenberg/outbound.go
Normal file
325
pkg/gotenberg/outbound.go
Normal file
@@ -0,0 +1,325 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
)
|
||||
|
||||
// ErrNonPublicIP indicates that an outbound URL targets an IP address that
|
||||
// is not reachable on the public internet. This covers loopback, RFC1918
|
||||
// private, link-local, unspecified, multicast, and IPv6 unique-local
|
||||
// (fc00::/7) addresses, as well as their IPv4-mapped IPv6 wrappers (for
|
||||
// example [::ffff:127.0.0.1]).
|
||||
var ErrNonPublicIP = errors.New("non-public IP")
|
||||
|
||||
// netipResolver is the subset of [net.Resolver] used by
|
||||
// [ResolveAndCheckPublic]. Defining it as an interface allows tests to
|
||||
// substitute a stub resolver.
|
||||
type netipResolver interface {
|
||||
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
|
||||
}
|
||||
|
||||
// outboundResolver is the resolver used by [ResolveAndCheckPublic]. It is a
|
||||
// package-level variable so that tests can substitute a stub resolver.
|
||||
var outboundResolver netipResolver = net.DefaultResolver
|
||||
|
||||
// outboundDialer is the underlying dialer used by [secureDialContext]. It is
|
||||
// a package-level variable so that tests can replace it.
|
||||
var outboundDialer = &net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}
|
||||
|
||||
// IsPublicIP reports whether addr is reachable on the public internet. It
|
||||
// returns false for loopback, private (RFC1918), link-local, unspecified,
|
||||
// multicast, and unique-local addresses. IPv4-mapped IPv6 addresses are
|
||||
// unmapped before evaluation so that [::ffff:127.0.0.1] is correctly
|
||||
// identified as loopback.
|
||||
func IsPublicIP(addr netip.Addr) bool {
|
||||
if !addr.IsValid() {
|
||||
return false
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
switch {
|
||||
case addr.IsLoopback(),
|
||||
addr.IsPrivate(),
|
||||
addr.IsLinkLocalUnicast(),
|
||||
addr.IsLinkLocalMulticast(),
|
||||
addr.IsMulticast(),
|
||||
addr.IsUnspecified(),
|
||||
addr.IsInterfaceLocalMulticast():
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ResolveAndCheckPublic resolves host and returns the resolved addresses,
|
||||
// or an error if any resolved address fails [IsPublicIP]. If host is itself
|
||||
// an IP literal, it is checked directly without performing a DNS lookup.
|
||||
// The returned slice can be used to pin a subsequent dial to a specific IP
|
||||
// and prevent DNS rebinding between this validation and the connect.
|
||||
func ResolveAndCheckPublic(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
if host == "" {
|
||||
return nil, errors.New("empty host")
|
||||
}
|
||||
if addr, err := netip.ParseAddr(host); err == nil {
|
||||
if !IsPublicIP(addr) {
|
||||
return nil, fmt.Errorf("%q: %w", addr, ErrNonPublicIP)
|
||||
}
|
||||
return []netip.Addr{addr}, nil
|
||||
}
|
||||
addrs, err := outboundResolver.LookupNetIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve %q: %w", host, err)
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return nil, fmt.Errorf("resolve %q: no addresses returned", host)
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if !IsPublicIP(a) {
|
||||
return nil, fmt.Errorf("%q resolves to non-public address %q: %w", host, a, ErrNonPublicIP)
|
||||
}
|
||||
}
|
||||
return addrs, nil
|
||||
}
|
||||
|
||||
// outboundDecision is the result of validating an outbound URL. It is
|
||||
// stashed in the request context by [outboundRoundTripper] so that
|
||||
// [secureDialContext] can either bypass the IP check (allow-list match) or
|
||||
// pin the dial to the IPs that were resolved at validation time.
|
||||
type outboundDecision struct {
|
||||
// bypass is true when an allow-list pattern matched the URL. In that
|
||||
// case the operator has explicitly opted into the destination and the
|
||||
// dial should proceed without an IP check.
|
||||
bypass bool
|
||||
|
||||
// pinned holds the IPs resolved by [ResolveAndCheckPublic] for the URL
|
||||
// host. The dial should be pinned to one of these to prevent DNS
|
||||
// rebinding between validation and connect.
|
||||
pinned []netip.Addr
|
||||
}
|
||||
|
||||
// outboundDecisionKey is the context key under which an [outboundDecision]
|
||||
// is stored.
|
||||
type outboundDecisionKey struct{}
|
||||
|
||||
// httpLikeScheme reports whether scheme is one of http, https, ws, or wss.
|
||||
// Only these schemes go through the IP-based public-address check; data,
|
||||
// blob, file, and other schemes are filtered by the regex layer alone.
|
||||
func httpLikeScheme(scheme string) bool {
|
||||
switch scheme {
|
||||
case "http", "https", "ws", "wss":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// decideOutbound parses rawURL, runs the regex allow/deny lists against the
|
||||
// normalized form, and (when no allow-list match) resolves the host and
|
||||
// rejects any non-public address. It returns the resulting
|
||||
// [outboundDecision] which the caller can stash in a context for the dial.
|
||||
func decideOutbound(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time) (outboundDecision, error) {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return outboundDecision{}, fmt.Errorf("parse URL %q: %w", rawURL, ErrFiltered)
|
||||
}
|
||||
parsed.Scheme = strings.ToLower(parsed.Scheme)
|
||||
parsed.Host = strings.ToLower(parsed.Host)
|
||||
normalized := parsed.String()
|
||||
|
||||
allowMatched := false
|
||||
if len(allowList) > 0 {
|
||||
for _, pattern := range allowList {
|
||||
clone := regexp2.MustCompile(pattern.String(), 0)
|
||||
clone.MatchTimeout = time.Until(deadline)
|
||||
|
||||
ok, err := clone.MatchString(normalized)
|
||||
if err != nil {
|
||||
if time.Now().After(deadline) {
|
||||
return outboundDecision{}, context.DeadlineExceeded
|
||||
}
|
||||
return outboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
|
||||
}
|
||||
|
||||
if ok {
|
||||
allowMatched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !allowMatched {
|
||||
return outboundDecision{}, fmt.Errorf("'%s' does not match any expression from the allowed list: %w", normalized, ErrFiltered)
|
||||
}
|
||||
}
|
||||
|
||||
for _, pattern := range denyList {
|
||||
clone := regexp2.MustCompile(pattern.String(), 0)
|
||||
clone.MatchTimeout = time.Until(deadline)
|
||||
|
||||
ok, err := clone.MatchString(normalized)
|
||||
if err != nil {
|
||||
if time.Now().After(deadline) {
|
||||
return outboundDecision{}, context.DeadlineExceeded
|
||||
}
|
||||
return outboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
|
||||
}
|
||||
|
||||
if ok {
|
||||
return outboundDecision{}, fmt.Errorf("'%s' matches the expression from the denied list: %w", normalized, ErrFiltered)
|
||||
}
|
||||
}
|
||||
|
||||
if allowMatched {
|
||||
return outboundDecision{bypass: true}, nil
|
||||
}
|
||||
|
||||
if !httpLikeScheme(parsed.Scheme) {
|
||||
return outboundDecision{}, nil
|
||||
}
|
||||
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
return outboundDecision{}, fmt.Errorf("URL %q has no host: %w", rawURL, ErrFiltered)
|
||||
}
|
||||
|
||||
addrs, err := ResolveAndCheckPublic(ctx, host)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNonPublicIP) {
|
||||
return outboundDecision{}, fmt.Errorf("'%s' targets a non-public address: %w", normalized, ErrFiltered)
|
||||
}
|
||||
return outboundDecision{}, fmt.Errorf("validate '%s' host: %w", normalized, err)
|
||||
}
|
||||
|
||||
return outboundDecision{pinned: addrs}, nil
|
||||
}
|
||||
|
||||
// FilterOutboundURL validates that rawURL is acceptable for an outbound
|
||||
// request from Gotenberg. It is the URL-aware replacement for
|
||||
// [FilterDeadline] and should be preferred for any new code that filters a
|
||||
// URL before issuing or instructing an outbound request.
|
||||
//
|
||||
// The function:
|
||||
//
|
||||
// 1. Parses rawURL with [net/url] and lowercases the scheme and host. This
|
||||
// prevents case-variant bypasses such as HTTP://127.0.0.1 from evading
|
||||
// case-sensitive deny-list regexes.
|
||||
// 2. Applies allowList and denyList against the normalized form using the
|
||||
// same OR semantics as [FilterDeadline].
|
||||
// 3. When no allow-list entry explicitly matched and the scheme is one of
|
||||
// http, https, ws, or wss, resolves the host and verifies every
|
||||
// resolved address with [IsPublicIP]. This blocks loopback, private,
|
||||
// link-local, and other internal targets even when the regex layer
|
||||
// does not cover the textual form (for example IPv4-mapped IPv6 like
|
||||
// [::ffff:127.0.0.1], or hostnames that resolve to a private address).
|
||||
//
|
||||
// An allow-list match bypasses the IP check, allowing operators to opt
|
||||
// into specific internal destinations via --*-allow-list flags. The
|
||||
// deny-list always applies and cannot be bypassed by an allow-list match.
|
||||
func FilterOutboundURL(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time) error {
|
||||
_, err := decideOutbound(ctx, rawURL, allowList, denyList, deadline)
|
||||
return err
|
||||
}
|
||||
|
||||
// outboundRoundTripper is an [http.RoundTripper] that validates each request
|
||||
// URL via [decideOutbound] and stashes the resulting [outboundDecision] in
|
||||
// the request context so that [secureDialContext] can pin the dial or
|
||||
// bypass the IP check as appropriate. Because the http.Client invokes
|
||||
// RoundTrip again for each redirect hop, this also re-validates redirect
|
||||
// targets without a separate CheckRedirect.
|
||||
type outboundRoundTripper struct {
|
||||
base http.RoundTripper
|
||||
allowList []*regexp2.Regexp
|
||||
denyList []*regexp2.Regexp
|
||||
}
|
||||
|
||||
// RoundTrip validates req.URL and delegates to the base transport.
|
||||
func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
deadline, ok := req.Context().Deadline()
|
||||
if !ok {
|
||||
deadline = time.Now().Add(30 * time.Second)
|
||||
}
|
||||
|
||||
decision, err := decideOutbound(req.Context(), req.URL.String(), rt.allowList, rt.denyList, deadline)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx := context.WithValue(req.Context(), outboundDecisionKey{}, decision)
|
||||
return rt.base.RoundTrip(req.WithContext(ctx))
|
||||
}
|
||||
|
||||
// NewOutboundHttpClient returns an [http.Client] that validates every
|
||||
// outbound request URL via the same logic as [FilterOutboundURL] and pins
|
||||
// the resulting dial to a resolved public IP. An allow-list match
|
||||
// (operator opt-in to a specific destination) bypasses the IP check.
|
||||
//
|
||||
// The client re-validates redirect targets automatically because the
|
||||
// underlying [http.Client] invokes the wrapping [http.RoundTripper] once
|
||||
// per hop. This closes the redirect-based SSRF bypass that affects raw
|
||||
// [http.Client] usage when no CheckRedirect is set.
|
||||
func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2.Regexp) *http.Client {
|
||||
base := http.DefaultTransport.(*http.Transport).Clone()
|
||||
base.DialContext = secureDialContext
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &outboundRoundTripper{
|
||||
base: base,
|
||||
allowList: allowList,
|
||||
denyList: denyList,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// secureDialContext consumes the [outboundDecision] stashed in ctx by
|
||||
// [outboundRoundTripper]. When the decision is to bypass (allow-list
|
||||
// match), it dials directly. When the decision contains pinned IPs, it
|
||||
// dials each in turn until one connects. When no decision is present (the
|
||||
// dialer was used outside of [outboundRoundTripper]), it falls back to
|
||||
// resolving and checking the destination itself.
|
||||
func secureDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("split host:port %q: %w", addr, err)
|
||||
}
|
||||
|
||||
if decision, ok := ctx.Value(outboundDecisionKey{}).(outboundDecision); ok {
|
||||
if decision.bypass {
|
||||
return outboundDialer.DialContext(ctx, network, addr)
|
||||
}
|
||||
if len(decision.pinned) > 0 {
|
||||
return dialPinned(ctx, network, decision.pinned, port)
|
||||
}
|
||||
}
|
||||
|
||||
addrs, err := ResolveAndCheckPublic(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dialPinned(ctx, network, addrs, port)
|
||||
}
|
||||
|
||||
// dialPinned dials each addr in turn until one connects, returning the
|
||||
// first successful connection or the last error.
|
||||
func dialPinned(ctx context.Context, network string, addrs []netip.Addr, port string) (net.Conn, error) {
|
||||
var lastErr error
|
||||
for _, a := range addrs {
|
||||
conn, err := outboundDialer.DialContext(ctx, network, net.JoinHostPort(a.String(), port))
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if lastErr == nil {
|
||||
return nil, errors.New("no addresses to dial")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
308
pkg/gotenberg/outbound_test.go
Normal file
308
pkg/gotenberg/outbound_test.go
Normal file
@@ -0,0 +1,308 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
)
|
||||
|
||||
func TestIsPublicIP(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
addr string
|
||||
public bool
|
||||
}{
|
||||
// Public.
|
||||
{"1.1.1.1", true},
|
||||
{"8.8.8.8", true},
|
||||
{"2606:4700:4700::1111", true},
|
||||
|
||||
// Loopback.
|
||||
{"127.0.0.1", false},
|
||||
{"127.255.255.254", false},
|
||||
{"::1", false},
|
||||
|
||||
// IPv4-mapped IPv6 (Issue 2).
|
||||
{"::ffff:127.0.0.1", false},
|
||||
{"::ffff:10.0.0.1", false},
|
||||
{"::ffff:169.254.169.254", false},
|
||||
|
||||
// RFC1918.
|
||||
{"10.0.0.1", false},
|
||||
{"172.16.0.1", false},
|
||||
{"172.31.255.254", false},
|
||||
{"192.168.1.1", false},
|
||||
|
||||
// Link-local.
|
||||
{"169.254.169.254", false},
|
||||
{"fe80::1", false},
|
||||
|
||||
// Unique-local.
|
||||
{"fc00::1", false},
|
||||
{"fd12:3456:789a::1", false},
|
||||
|
||||
// Unspecified.
|
||||
{"0.0.0.0", false},
|
||||
{"::", false},
|
||||
|
||||
// Multicast.
|
||||
{"224.0.0.1", false},
|
||||
{"ff02::1", false},
|
||||
} {
|
||||
t.Run(tc.addr, func(t *testing.T) {
|
||||
addr, err := netip.ParseAddr(tc.addr)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", tc.addr, err)
|
||||
}
|
||||
if got := IsPublicIP(addr); got != tc.public {
|
||||
t.Fatalf("IsPublicIP(%q) = %v, want %v", tc.addr, got, tc.public)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// stubResolver lets tests fake DNS lookups in [ResolveAndCheckPublic].
|
||||
type stubResolver struct {
|
||||
lookup func(host string) ([]netip.Addr, error)
|
||||
}
|
||||
|
||||
func (s stubResolver) LookupNetIP(_ context.Context, _, host string) ([]netip.Addr, error) {
|
||||
return s.lookup(host)
|
||||
}
|
||||
|
||||
func withStubResolver(t *testing.T, fn func(host string) ([]netip.Addr, error)) {
|
||||
t.Helper()
|
||||
prev := outboundResolver
|
||||
outboundResolver = stubResolver{lookup: fn}
|
||||
t.Cleanup(func() { outboundResolver = prev })
|
||||
}
|
||||
|
||||
func mustAddrs(t *testing.T, ss ...string) []netip.Addr {
|
||||
t.Helper()
|
||||
out := make([]netip.Addr, 0, len(ss))
|
||||
for _, s := range ss {
|
||||
a, err := netip.ParseAddr(s)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", s, err)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestFilterOutboundURL(t *testing.T) {
|
||||
defaultDeny := []*regexp2.Regexp{
|
||||
regexp2.MustCompile(`^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)`, 0),
|
||||
}
|
||||
chromiumDeny := []*regexp2.Regexp{
|
||||
regexp2.MustCompile(`^file:(?!//\/tmp/).*`, 0),
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
rawURL string
|
||||
allow []*regexp2.Regexp
|
||||
deny []*regexp2.Regexp
|
||||
stub func(host string) ([]netip.Addr, error)
|
||||
expectErr bool
|
||||
expectIs error
|
||||
expectErrMsg string
|
||||
}{
|
||||
{
|
||||
scenario: "public IP literal passes",
|
||||
rawURL: "https://1.1.1.1/",
|
||||
deny: defaultDeny,
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
scenario: "loopback IP literal blocked by default deny-list",
|
||||
rawURL: "http://127.0.0.1:8080/",
|
||||
deny: defaultDeny,
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "Issue 4: uppercase scheme normalized then blocked by deny-list",
|
||||
rawURL: "HTTP://127.0.0.1:8080/",
|
||||
deny: defaultDeny,
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "Issue 2: IPv4-mapped IPv6 evades deny-list but blocked by IP check",
|
||||
rawURL: "http://[::ffff:127.0.0.1]:8080/page.pdf",
|
||||
deny: defaultDeny,
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "Issue 2: IPv4-mapped IPv6 to RFC1918 blocked by IP check",
|
||||
rawURL: "http://[::ffff:10.0.0.1]/",
|
||||
deny: defaultDeny,
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "hostname resolving to public IP passes",
|
||||
rawURL: "https://example.com/",
|
||||
deny: defaultDeny,
|
||||
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "93.184.216.34"), nil },
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
scenario: "hostname resolving to loopback blocked",
|
||||
rawURL: "https://rebind.example/",
|
||||
deny: defaultDeny,
|
||||
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "127.0.0.1"), nil },
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "hostname resolving to mixed public+private blocked",
|
||||
rawURL: "https://mixed.example/",
|
||||
deny: defaultDeny,
|
||||
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "1.1.1.1", "10.0.0.1"), nil },
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "allow-list match bypasses IP check",
|
||||
rawURL: "http://internal.service/api",
|
||||
allow: []*regexp2.Regexp{regexp2.MustCompile(`^http://internal\.service`, 0)},
|
||||
deny: defaultDeny,
|
||||
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "10.0.0.1"), nil },
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
scenario: "deny-list still wins over allow-list match",
|
||||
rawURL: "http://internal.service/api",
|
||||
allow: []*regexp2.Regexp{regexp2.MustCompile(`^http://internal`, 0)},
|
||||
deny: []*regexp2.Regexp{regexp2.MustCompile(`/api$`, 0)},
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "allow-list non-empty and no match rejects",
|
||||
rawURL: "https://other.example/",
|
||||
allow: []*regexp2.Regexp{regexp2.MustCompile(`^https://allowed\.example`, 0)},
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "file:// allowed under tmp passes Chromium default",
|
||||
rawURL: "file:///tmp/index.html",
|
||||
deny: chromiumDeny,
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
scenario: "file:// outside tmp blocked by Chromium default",
|
||||
rawURL: "file:///etc/passwd",
|
||||
deny: chromiumDeny,
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "Issue 1: Chromium default does not block http to public host (regex layer)",
|
||||
rawURL: "https://example.com/",
|
||||
deny: chromiumDeny,
|
||||
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "93.184.216.34"), nil },
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
scenario: "Issue 1: Chromium default now blocks http to loopback via IP layer",
|
||||
rawURL: "http://127.0.0.1:3000/health",
|
||||
deny: chromiumDeny,
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "Issue 1: Chromium default now blocks cloud metadata via IP layer",
|
||||
rawURL: "http://169.254.169.254/latest/meta-data/",
|
||||
deny: chromiumDeny,
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "data: URL passes (non-network scheme)",
|
||||
rawURL: "data:text/html;base64,PGgxPmhpPC9oMT4=",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
scenario: "URL with no host rejected",
|
||||
rawURL: "http:///path",
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "userinfo cannot mask host",
|
||||
rawURL: "http://example.com@127.0.0.1/",
|
||||
deny: defaultDeny,
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.stub != nil {
|
||||
withStubResolver(t, tc.stub)
|
||||
} else {
|
||||
// Default: any DNS lookup in a non-stubbed test is a bug.
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
t.Fatalf("unexpected DNS lookup for %q", host)
|
||||
return nil, nil
|
||||
})
|
||||
}
|
||||
|
||||
err := FilterOutboundURL(context.Background(), tc.rawURL, tc.allow, tc.deny, time.Now().Add(5*time.Second))
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if tc.expectIs != nil && !errors.Is(err, tc.expectIs) {
|
||||
t.Fatalf("expected error to wrap %v, got: %v", tc.expectIs, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAndCheckPublic_IPLiteralLoopback(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
t.Fatalf("unexpected DNS lookup for %q", host)
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
_, err := ResolveAndCheckPublic(context.Background(), "127.0.0.1")
|
||||
if !errors.Is(err, ErrNonPublicIP) {
|
||||
t.Fatalf("expected ErrNonPublicIP, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAndCheckPublic_HostResolvesToLoopback(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
return mustAddrs(t, "127.0.0.1"), nil
|
||||
})
|
||||
|
||||
_, err := ResolveAndCheckPublic(context.Background(), "rebind.example")
|
||||
if !errors.Is(err, ErrNonPublicIP) {
|
||||
t.Fatalf("expected ErrNonPublicIP, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAndCheckPublic_HostResolvesToPublic(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
return mustAddrs(t, "1.1.1.1"), nil
|
||||
})
|
||||
|
||||
addrs, err := ResolveAndCheckPublic(context.Background(), "example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(addrs) != 1 || addrs[0].String() != "1.1.1.1" {
|
||||
t.Fatalf("expected [1.1.1.1], got: %v", addrs)
|
||||
}
|
||||
}
|
||||
@@ -201,6 +201,12 @@ type PdfEngine interface {
|
||||
// TODO: attachments instead? Rename the route?
|
||||
EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
|
||||
|
||||
// EmbedFilesMetadata sets metadata (such as MIME type and AFRelationship)
|
||||
// on already-embedded files in a PDF. The metadata map is keyed by
|
||||
// filename, with each value being a map of property names to values
|
||||
// (e.g., "mimeType" and "relationship").
|
||||
EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error
|
||||
|
||||
// Watermark applies a watermark (behind page content) to a PDF file.
|
||||
Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// Package semconv is a copy/paste of utilities that are currently not exposed
|
||||
// in the OpenTelemery Go SDK.
|
||||
// Package semconv contains utilities not yet exposed in the OpenTelemetry Go
|
||||
// SDK. Remove this package once an official API exists.
|
||||
//
|
||||
// This package MUST be removed once an "official" API is provided.
|
||||
//
|
||||
// See: https://github.com/open-telemetry/opentelemetry-go-contrib/issues/4580.
|
||||
// See https://github.com/open-telemetry/opentelemetry-go-contrib/issues/4580.
|
||||
package semconv
|
||||
|
||||
@@ -333,7 +333,7 @@ func (s *processSupervisor) maybeIdleShutdown() {
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.InfoContext(context.Background(), "idle shutdown timeout reached, stopping process")
|
||||
s.logger.DebugContext(context.Background(), "idle shutdown timeout reached, stopping process")
|
||||
|
||||
// Stop the ticker — it will be restarted on the next Launch().
|
||||
s.stopIdleTicker()
|
||||
@@ -350,7 +350,7 @@ func (s *processSupervisor) maybeIdleShutdown() {
|
||||
s.firstStartErr = nil
|
||||
s.reqCounter.Store(0)
|
||||
|
||||
s.logger.InfoContext(context.Background(), "process stopped due to idle timeout")
|
||||
s.logger.DebugContext(context.Background(), "process stopped due to idle timeout")
|
||||
}
|
||||
|
||||
// acquireSlot attempts to acquire a semaphore slot, yielding it back if a
|
||||
|
||||
@@ -196,7 +196,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
||||
fs.String("api-correlation-id-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
|
||||
fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables")
|
||||
fs.StringSlice("api-download-from-allow-list", []string{}, "Set the allowed URLs for the download from feature using regular expressions - supports multiple values")
|
||||
fs.StringSlice("api-download-from-deny-list", []string{}, "Set the denied URLs for the download from feature using regular expressions - supports multiple values")
|
||||
fs.StringSlice("api-download-from-deny-list", []string{`^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)`}, "Set the denied URLs for the download from feature using regular expressions - supports multiple values")
|
||||
fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature")
|
||||
fs.Bool("api-disable-download-from", false, "Disable the download from feature")
|
||||
fs.Bool("api-disable-health-check-route-telemetry", true, "Disable telemetry for health check route")
|
||||
|
||||
@@ -232,7 +232,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
)
|
||||
}
|
||||
|
||||
err := gotenberg.FilterDeadline(downloadFromCfg.allowList, downloadFromCfg.denyList, dl.Url, deadline)
|
||||
err := gotenberg.FilterOutboundURL(ctx, dl.Url, downloadFromCfg.allowList, downloadFromCfg.denyList, deadline)
|
||||
if err != nil {
|
||||
return fmt.Errorf("filter URL: %w", err)
|
||||
}
|
||||
@@ -268,9 +268,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
}
|
||||
|
||||
client := &retryablehttp.Client{
|
||||
HTTPClient: &http.Client{
|
||||
Timeout: time.Until(deadline),
|
||||
},
|
||||
HTTPClient: gotenberg.NewOutboundHttpClient(time.Until(deadline), downloadFromCfg.allowList, downloadFromCfg.denyList),
|
||||
RetryMax: downloadFromCfg.maxRetry,
|
||||
RetryWaitMin: time.Duration(1) * time.Second,
|
||||
RetryWaitMax: time.Until(deadline),
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// Package api provides a module, which is an HTTP server. Other modules may
|
||||
// add multipart/form-data routes, middlewares, and health checks.
|
||||
// Package api implements the HTTP server module. Other modules register
|
||||
// multipart/form data routes, middlewares, and health checks through it.
|
||||
package api
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
@@ -391,6 +392,38 @@ func (form *FormData) Embeds(target *[]string) *FormData {
|
||||
return form
|
||||
}
|
||||
|
||||
// EmbedsMetadata parses the "embedsMetadata" form field (a JSON string) into
|
||||
// a map keyed by filename. Each value is a map of property names to values
|
||||
// (e.g., "mimeType" and "relationship").
|
||||
//
|
||||
// var metadata map[string]map[string]string
|
||||
//
|
||||
// ctx.FormData().EmbedsMetadata(&metadata)
|
||||
func (form *FormData) EmbedsMetadata(target *map[string]map[string]string) *FormData {
|
||||
if form.errors != nil {
|
||||
return form
|
||||
}
|
||||
|
||||
val, ok := form.values["embedsMetadata"]
|
||||
if !ok || len(val) == 0 || val[0] == "" {
|
||||
return form
|
||||
}
|
||||
|
||||
raw := val[0]
|
||||
parsed := make(map[string]map[string]string)
|
||||
|
||||
err := json.Unmarshal([]byte(raw), &parsed)
|
||||
if err != nil {
|
||||
form.append(
|
||||
fmt.Errorf("form field 'embedsMetadata' is invalid: %w", err),
|
||||
)
|
||||
return form
|
||||
}
|
||||
|
||||
*target = parsed
|
||||
return form
|
||||
}
|
||||
|
||||
// MandatoryPaths binds the absolute paths of form data files, according to a
|
||||
// list of file extensions, to a string slice variable. It populates an error
|
||||
// if there is no file for given file extensions.
|
||||
|
||||
@@ -336,8 +336,9 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
||||
return errors.New("context has no deadline")
|
||||
}
|
||||
|
||||
// We validate the "main" URL against our allowed / deny lists.
|
||||
err := gotenberg.FilterDeadline(b.arguments.allowList, b.arguments.denyList, url, deadline)
|
||||
// We validate the "main" URL against our allowed / deny lists, and
|
||||
// against the IP-based outbound URL guard. See [gotenberg.FilterOutboundURL].
|
||||
err := gotenberg.FilterOutboundURL(ctx, url, b.arguments.allowList, b.arguments.denyList, deadline)
|
||||
if err != nil {
|
||||
return fmt.Errorf("filter URL: %w", err)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package chromium provides a module which adds routes for converting HTML
|
||||
// documents to PDF. Other modules may also retrieve the [Api] provided by this
|
||||
// module.
|
||||
// Package chromium adds routes for converting HTML documents to PDF. Exposes
|
||||
// an [Api] for other modules.
|
||||
package chromium
|
||||
|
||||
@@ -52,7 +52,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
|
||||
return
|
||||
}
|
||||
|
||||
err := gotenberg.FilterDeadline(options.allowList, options.denyList, e.Request.URL, deadline)
|
||||
err := gotenberg.FilterOutboundURL(ctx, e.Request.URL, options.allowList, options.denyList, deadline)
|
||||
if err != nil {
|
||||
logger.WarnContext(ctx, err.Error())
|
||||
allow = false
|
||||
@@ -81,6 +81,15 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
|
||||
executorCtx := cdp.WithExecutor(ctx, cctx.Target)
|
||||
|
||||
if !allow {
|
||||
// Use AccessDenied so Chromium emits net::ERR_ACCESS_DENIED,
|
||||
// which is intentionally absent from the EventLoadingFailed
|
||||
// known-errors list. Routing through BlockedByClient would
|
||||
// surface the failure, but the Document-type dispatcher in
|
||||
// listenForEventLoadingFailed cannot distinguish a blocked
|
||||
// iframe (sub-frame Document) from a main-page Document, and
|
||||
// would attribute the iframe failure to the main page.
|
||||
// Filter-block observability is provided by the warn log
|
||||
// above instead.
|
||||
req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
|
||||
err = req.Do(executorCtx)
|
||||
if err != nil {
|
||||
|
||||
@@ -202,6 +202,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
err = errors.Join(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile))
|
||||
continue
|
||||
}
|
||||
p.MatchTimeout = 5 * time.Second
|
||||
scopeRegexp = p
|
||||
}
|
||||
|
||||
@@ -420,6 +421,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
stamp := pdfengines.FormDataPdfStamp(form, false)
|
||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||
|
||||
var url string
|
||||
err := form.
|
||||
@@ -436,7 +438,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
stamp.Expression = stampFile
|
||||
}
|
||||
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages)
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert URL to PDF: %w", err)
|
||||
}
|
||||
@@ -495,6 +497,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
stamp := pdfengines.FormDataPdfStamp(form, false)
|
||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||
|
||||
var inputPath string
|
||||
err := form.
|
||||
@@ -513,7 +516,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
|
||||
url := fmt.Sprintf("file://%s", inputPath)
|
||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages)
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert HTML to PDF: %w", err)
|
||||
}
|
||||
@@ -574,6 +577,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
stamp := pdfengines.FormDataPdfStamp(form, false)
|
||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||
|
||||
var (
|
||||
inputPath string
|
||||
@@ -601,7 +605,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
}
|
||||
|
||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages)
|
||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert markdown to PDF: %w", err)
|
||||
}
|
||||
@@ -726,7 +730,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
|
||||
return fmt.Sprintf("file://%s", inputPath), nil
|
||||
}
|
||||
|
||||
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, embedPaths []string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error {
|
||||
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, embedPaths []string, embedsMetadata map[string]map[string]string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error {
|
||||
outputPath := ctx.GeneratePath(".pdf")
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1130.
|
||||
filename := ctx.OutputFilename(outputPath)
|
||||
@@ -830,6 +834,11 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
||||
return fmt.Errorf("embed files into PDFs: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.EmbedFilesMetadataStub(ctx, engine, embedsMetadata, convertOutputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set embeds metadata: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
// Package exiftool provides an implementation of the gotenberg.PdfEngine
|
||||
// interface using the ExifTool command-line tool. This package allows for:
|
||||
// Package exiftool implements gotenberg.PdfEngine using the ExifTool command-line tool. Reads and writes PDF metadata.
|
||||
//
|
||||
// 1. The reading of metadata.
|
||||
// 2. The writing of metadata.
|
||||
// Requires the EXIFTOOL_BIN_PATH environment variable.
|
||||
//
|
||||
// The path to the exiftool binary must be specified using the
|
||||
// EXIFTOOL_BIN_PATH environment variable.
|
||||
//
|
||||
// See: https://exiftool.org.
|
||||
// See https://exiftool.org.
|
||||
package exiftool
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
@@ -23,6 +24,58 @@ func init() {
|
||||
gotenberg.MustRegisterModule(new(ExifTool))
|
||||
}
|
||||
|
||||
// safeKeyPattern matches legitimate ExifTool tag names: alphanumeric,
|
||||
// hyphens, underscores, colons, and periods. Rejects control characters
|
||||
// (especially \n) that would inject stdin arguments via go-exiftool's
|
||||
// line-based protocol.
|
||||
var safeKeyPattern = regexp.MustCompile(`^[a-zA-Z0-9\-_.:]+$`)
|
||||
|
||||
// validateMetadataValue rejects metadata values containing characters that
|
||||
// could inject ExifTool stdin arguments. go-exiftool writes each key/value
|
||||
// pair as a single line via fmt.Fprintln(stdin, "-"+k+"="+str), so a
|
||||
// newline in the value splits into a second stdin line that ExifTool
|
||||
// interprets as an additional argument. An attacker who controls a
|
||||
// metadata value but not the key could otherwise inject pseudo-tags like
|
||||
// -FileName=, -Directory=, -SymLink=, or -HardLink= and trigger arbitrary
|
||||
// filesystem side effects. The same applies to carriage returns and NUL.
|
||||
//
|
||||
// The returned error wraps [gotenberg.ErrPdfEngineMetadataValueNotSupported]
|
||||
// so the API layer surfaces it as HTTP 400.
|
||||
func validateMetadataValue(key, value string) error {
|
||||
if strings.ContainsAny(value, "\n\r\x00") {
|
||||
return fmt.Errorf("write PDF metadata with ExifTool: invalid metadata value for key %q (contains control character): %w", key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// systemTags lists ExifTool tags that reflect internal filesystem state
|
||||
// rather than actual PDF metadata. These are stripped from both read and
|
||||
// write operations.
|
||||
var systemTags = []string{
|
||||
"FileName", // Reflects UUID-based disk name, not original filename
|
||||
"Directory", // Leaks internal temp path
|
||||
"FileSize", // System attribute
|
||||
"FileModifyDate", // System attribute
|
||||
"FileAccessDate", // System attribute
|
||||
"FileInodeChangeDate", // System attribute
|
||||
"FilePermissions", // System attribute
|
||||
"ExifToolVersion", // Tool metadata
|
||||
"Error", // Extraction error messages
|
||||
"Warning", // Extraction warning messages
|
||||
}
|
||||
|
||||
// writeOnlyDerivedTags lists ExifTool tags that are safe to return when
|
||||
// reading metadata but should not be written back (writing them can break
|
||||
// PDF/A compliance or cause side effects).
|
||||
var writeOnlyDerivedTags = []string{
|
||||
"PageCount", // Causes prism:pageCount injection
|
||||
"Linearized", // Computed status; writing it may invalidate structure
|
||||
"PDFVersion", // Header version; should not be manually forced via metadata
|
||||
"MIMEType", // Read-only derived
|
||||
"FileType", // Read-only derived
|
||||
"FileTypeExtension", // Read-only derived
|
||||
}
|
||||
|
||||
// ExifTool abstracts the CLI tool ExifTool and implements the
|
||||
// [gotenberg.PdfEngine] interface.
|
||||
type ExifTool struct {
|
||||
@@ -163,6 +216,12 @@ func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *slog.Logger, i
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Strip system tags that reflect internal filesystem state (e.g.,
|
||||
// UUID-based FileName, temp Directory) rather than actual PDF metadata.
|
||||
for _, tag := range systemTags {
|
||||
delete(fileMetadata[0].Fields, tag)
|
||||
}
|
||||
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return fileMetadata[0].Fields, nil
|
||||
}
|
||||
@@ -198,62 +257,95 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *slog.Logger,
|
||||
return err
|
||||
}
|
||||
|
||||
// Define a list of derived, system, or computed tags that ExifTool
|
||||
// extracts but should never be written back. Writing these can break PDF/A
|
||||
// compliance (e.g., PageCount -> prism:pageCount) or cause side effects
|
||||
// (e.g., FileModifyDate).
|
||||
derivedTags := []string{
|
||||
"PageCount", // Causes prism:pageCount injection
|
||||
"Linearized", // Computed status; writing it may invalidate structure
|
||||
"PDFVersion", // Header version; should not be manually forced via metadata
|
||||
"MIMEType", // Read-only derived
|
||||
"FileType", // Read-only derived
|
||||
"FileTypeExtension", // Read-only derived
|
||||
"FileSize", // System attribute
|
||||
"FileModifyDate", // System attribute
|
||||
"FileAccessDate", // System attribute
|
||||
"FileInodeChangeDate", // System attribute
|
||||
"FilePermissions", // System attribute
|
||||
"FileName", // Writing this triggers a file rename in ExifTool
|
||||
"Directory", // System attribute
|
||||
"ExifToolVersion", // Tool metadata
|
||||
"Error", // Extraction error messages
|
||||
"Warning", // Extraction warning messages
|
||||
// Strip system and derived tags from the existing file metadata so
|
||||
// they are not written back (which can break PDF/A compliance or
|
||||
// cause side effects).
|
||||
for _, tag := range systemTags {
|
||||
delete(fileMetadata[0].Fields, tag)
|
||||
}
|
||||
|
||||
for _, tag := range derivedTags {
|
||||
for _, tag := range writeOnlyDerivedTags {
|
||||
delete(fileMetadata[0].Fields, tag)
|
||||
}
|
||||
|
||||
// Filter user-supplied metadata to prevent ExifTool pseudo-tags from
|
||||
// triggering dangerous side effects like file renames or moves.
|
||||
// triggering dangerous side effects like file renames, moves, link
|
||||
// creation, or permission changes. Comparison is case-insensitive
|
||||
// because ExifTool processes tag names case-insensitively, and group
|
||||
// prefixes are stripped because ExifTool treats "System:FileName" the
|
||||
// same as "FileName".
|
||||
// See https://exiftool.org/TagNames/Extra.html.
|
||||
dangerousTags := []string{
|
||||
"FileName", // Writing this triggers a file rename in ExifTool
|
||||
"Directory", // Writing this triggers a file move in ExifTool
|
||||
"FileName", // Writing this triggers a file rename in ExifTool
|
||||
"Directory", // Writing this triggers a file move in ExifTool
|
||||
"HardLink", // Writing this creates a hard link in ExifTool
|
||||
"SymLink", // Writing this creates a symbolic link in ExifTool
|
||||
"FilePermissions", // Writing this changes the file's permissions
|
||||
}
|
||||
for _, tag := range dangerousTags {
|
||||
delete(metadata, tag)
|
||||
// Reject metadata keys containing characters that could inject ExifTool
|
||||
// stdin arguments. ExifTool uses a line-based stdin protocol; a newline
|
||||
// in a key splits into a separate argument, enabling flag injection
|
||||
// (e.g., -if with Perl eval). Only allow alphanumeric, hyphen, colon,
|
||||
// period, and underscore — sufficient for all legitimate tag names.
|
||||
for key := range metadata {
|
||||
if !safeKeyPattern.MatchString(key) {
|
||||
err = fmt.Errorf("write PDF metadata with ExifTool: invalid metadata key %q: %w", key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for key := range metadata {
|
||||
// Strip ExifTool group prefixes (e.g., "System:FileName" →
|
||||
// "FileName") before comparing. ExifTool allows leading group
|
||||
// names separated by colons, and treats the prefixed and bare
|
||||
// forms identically.
|
||||
bare := key
|
||||
if i := strings.LastIndex(key, ":"); i >= 0 {
|
||||
bare = key[i+1:]
|
||||
}
|
||||
for _, tag := range dangerousTags {
|
||||
if strings.EqualFold(bare, tag) {
|
||||
delete(metadata, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for key, value := range metadata {
|
||||
switch val := value.(type) {
|
||||
case string:
|
||||
if err = validateMetadataValue(key, val); err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
fileMetadata[0].SetString(key, val)
|
||||
case []string:
|
||||
for _, s := range val {
|
||||
if err = validateMetadataValue(key, s); err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
fileMetadata[0].SetStrings(key, val)
|
||||
case []any:
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1048.
|
||||
strs := make([]string, len(val))
|
||||
for i, entry := range val {
|
||||
if str, ok := entry.(string); ok {
|
||||
strs[i] = str
|
||||
continue
|
||||
str, ok := entry.(string)
|
||||
if !ok {
|
||||
err = fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeFor[[]any](), gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
err = fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeFor[[]any](), gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
if err = validateMetadataValue(key, str); err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
strs[i] = str
|
||||
}
|
||||
fileMetadata[0].SetStrings(key, strs)
|
||||
case bool:
|
||||
@@ -439,6 +531,11 @@ func (engine *ExifTool) Rotate(ctx context.Context, logger *slog.Logger, inputPa
|
||||
return err
|
||||
}
|
||||
|
||||
// EmbedFilesMetadata is not available in this implementation.
|
||||
func (engine *ExifTool) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
|
||||
return fmt.Errorf("set embeds metadata with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*ExifTool)(nil)
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
// Package api provides a module which manages a LibreOffice instance and
|
||||
// interacts with it via the UNO (Universal Network Objects) API.
|
||||
// Package api manages a LibreOffice instance via the UNO API.
|
||||
package api
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// Package libreoffice provides a module which adds a route for converting
|
||||
// documents to PDF with LibreOffice.
|
||||
// Package libreoffice adds a route for converting documents to PDF with
|
||||
// LibreOffice.
|
||||
package libreoffice
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// Package pdfengine provides a module which interacts with LibreOffice via the
|
||||
// UNO (Universal Network Objects) API and implements the gotenberg.PdfEngine
|
||||
// interface. This package allows for:
|
||||
//
|
||||
// 1. The conversion to specific PDF formats.
|
||||
// Package pdfengine implements gotenberg.PdfEngine using LibreOffice via the
|
||||
// UNO API. Converts PDFs to specific PDF formats.
|
||||
package pdfengine
|
||||
|
||||
@@ -115,6 +115,11 @@ func (engine *LibreOfficePdfEngine) EmbedFiles(ctx context.Context, logger *slog
|
||||
return fmt.Errorf("embed files with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// EmbedFilesMetadata is not available in this implementation.
|
||||
func (engine *LibreOfficePdfEngine) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
|
||||
return fmt.Errorf("set embeds metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// Watermark is not available in this implementation.
|
||||
func (engine *LibreOfficePdfEngine) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||
return fmt.Errorf("watermark PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
|
||||
@@ -37,6 +37,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
||||
stamp := pdfengines.FormDataPdfStamp(form, false)
|
||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||
angle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||
|
||||
zeroValuedSplitMode := gotenberg.SplitMode{}
|
||||
|
||||
@@ -495,6 +496,11 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
||||
return fmt.Errorf("embed files into PDFs: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.EmbedFilesMetadataStub(ctx, engine, embedsMetadata, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set embeds metadata: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
// Package pdfcpu provides an implementation of the gotenberg.PdfEngine
|
||||
// interface using the pdfcpu command-line tool. This package allows for:
|
||||
// Package pdfcpu implements gotenberg.PdfEngine using the pdfcpu command-line
|
||||
// tool. Merges and splits PDF files.
|
||||
//
|
||||
// 1. The merging of PDF files.
|
||||
// 2. The splitting of PDF files.
|
||||
//
|
||||
// See: https://github.com/pdfcpu/pdfcpu.
|
||||
// See https://github.com/pdfcpu/pdfcpu.
|
||||
package pdfcpu
|
||||
|
||||
@@ -447,6 +447,11 @@ func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *slog.Logger, i
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmbedFilesMetadata is not available in this implementation.
|
||||
func (engine *PdfCpu) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
|
||||
return fmt.Errorf("set embeds metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
|
||||
// without modifying the main PDF content.
|
||||
func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# Adding PDF Engine Features
|
||||
|
||||
When adding a new PDF engine capability (e.g., bookmarks, watermark, stamp, embed), you must update the Makefile to include the corresponding engine list variable and flag. Every `--pdfengines-*-engines` flag registered in `pkg/modules/pdfengines/pdfengines.go` must have a matching entry in the Makefile:
|
||||
|
||||
1. **Add a variable** in the Makefile's variable block (around line 60-70):
|
||||
```makefile
|
||||
PDFENGINES_<FEATURE>_ENGINES=<default engines>
|
||||
```
|
||||
2. **Add the flag** in the Makefile's command args block (around line 140-155):
|
||||
```makefile
|
||||
--pdfengines-<feature>-engines=$(PDFENGINES_<FEATURE>_ENGINES) \
|
||||
```
|
||||
|
||||
The default value should match what is defined in `pdfengines.go`'s `fs.StringSlice(...)` call for that flag.
|
||||
35
pkg/modules/pdfengines/README.md
Normal file
35
pkg/modules/pdfengines/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Adding PDF Engine Features
|
||||
|
||||
Each new PDF engine capability (bookmarks, watermark, stamp, embed, etc.) requires a matching Makefile entry. The Makefile variables control which engines are passed to Gotenberg at `make run` and `make test-integration` time via `compose.yaml`. Skip this step and `make run` falls back to the default defined in `pdfengines.go`, which may not include the new engine.
|
||||
|
||||
Every `--pdfengines-*-engines` flag registered in `pdfengines.go` needs two additions:
|
||||
|
||||
1. A variable in the Makefile's variable block (around line 60-70):
|
||||
|
||||
```makefile
|
||||
PDFENGINES_<FEATURE>_ENGINES=<default engines>
|
||||
```
|
||||
|
||||
2. A flag in `compose.yaml`'s command args:
|
||||
|
||||
```yaml
|
||||
- "--pdfengines-<feature>-engines=${PDFENGINES_<FEATURE>_ENGINES}"
|
||||
```
|
||||
|
||||
The default value must match the `fs.StringSlice(...)` call for that flag in `pdfengines.go`.
|
||||
|
||||
## Example: Rotate
|
||||
|
||||
Rotate was added with two engines (`pdfcpu` and `pdftk`):
|
||||
|
||||
**Makefile** (variable block):
|
||||
|
||||
```makefile
|
||||
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
|
||||
```
|
||||
|
||||
**compose.yaml** (command args):
|
||||
|
||||
```yaml
|
||||
- "--pdfengines-rotate-engines=${PDFENGINES_ROTATE_ENGINES}"
|
||||
```
|
||||
@@ -1,3 +1,3 @@
|
||||
// Package pdfengines a way to gather and manage multiple modules that
|
||||
// implement the gotenberg.PdfEngine interface.
|
||||
// Package pdfengines gathers and manages modules that implement
|
||||
// gotenberg.PdfEngine.
|
||||
package pdfengines
|
||||
|
||||
@@ -22,6 +22,7 @@ type multiPdfEngines struct {
|
||||
writeMetadataEngines []gotenberg.PdfEngine
|
||||
passwordEngines []gotenberg.PdfEngine
|
||||
embedEngines []gotenberg.PdfEngine
|
||||
embedMetadataEngines []gotenberg.PdfEngine
|
||||
readBookmarksEngines []gotenberg.PdfEngine
|
||||
writeBookmarksEngines []gotenberg.PdfEngine
|
||||
watermarkEngines []gotenberg.PdfEngine
|
||||
@@ -38,6 +39,7 @@ func newMultiPdfEngines(
|
||||
writeMetadataEngines,
|
||||
passwordEngines,
|
||||
embedEngines,
|
||||
embedMetadataEngines,
|
||||
readBookmarksEngines,
|
||||
writeBookmarksEngines,
|
||||
watermarkEngines,
|
||||
@@ -53,6 +55,7 @@ func newMultiPdfEngines(
|
||||
writeMetadataEngines: writeMetadataEngines,
|
||||
passwordEngines: passwordEngines,
|
||||
embedEngines: embedEngines,
|
||||
embedMetadataEngines: embedMetadataEngines,
|
||||
readBookmarksEngines: readBookmarksEngines,
|
||||
writeBookmarksEngines: writeBookmarksEngines,
|
||||
watermarkEngines: watermarkEngines,
|
||||
@@ -603,6 +606,43 @@ func (multi *multiPdfEngines) Rotate(ctx context.Context, logger *slog.Logger, i
|
||||
return err
|
||||
}
|
||||
|
||||
// EmbedFilesMetadata sets metadata on embedded files using the first available
|
||||
// engine that supports it.
|
||||
//
|
||||
//nolint:dupl
|
||||
func (multi *multiPdfEngines) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
|
||||
tracer := gotenberg.Tracer()
|
||||
ctx, span := tracer.Start(ctx, "pdfengines.EmbedFilesMetadata", trace.WithSpanKind(trace.SpanKindInternal))
|
||||
defer span.End()
|
||||
|
||||
var err error
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
for _, engine := range multi.embedMetadataEngines {
|
||||
go func(engine gotenberg.PdfEngine) {
|
||||
errChan <- engine.EmbedFilesMetadata(ctx, logger, metadata, inputPath)
|
||||
}(engine)
|
||||
|
||||
select {
|
||||
case setErr := <-errChan:
|
||||
if setErr != nil {
|
||||
err = errors.Join(err, setErr)
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
err = fmt.Errorf("set embeds metadata using multi PDF engines: %w", err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.PdfEngine = (*multiPdfEngines)(nil)
|
||||
|
||||
@@ -36,6 +36,7 @@ type PdfEngines struct {
|
||||
writeMetadataNames []string
|
||||
encryptNames []string
|
||||
embedNames []string
|
||||
embedMetadataNames []string
|
||||
readBookmarksNames []string
|
||||
writeBookmarksNames []string
|
||||
watermarkNames []string
|
||||
@@ -59,6 +60,7 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
|
||||
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-embed-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the file embedding feature - empty means all")
|
||||
fs.StringSlice("pdfengines-embed-metadata-engines", []string{"qpdf"}, "Set the PDF engines and their order for the embed metadata feature - empty means all")
|
||||
fs.StringSlice("pdfengines-read-bookmarks-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the read bookmarks feature - empty means all")
|
||||
fs.StringSlice("pdfengines-write-bookmarks-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the write bookmarks feature - empty means all")
|
||||
fs.StringSlice("pdfengines-watermark-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the watermark feature - empty means all")
|
||||
@@ -91,6 +93,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
|
||||
writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
|
||||
encryptNames := flags.MustStringSlice("pdfengines-encrypt-engines")
|
||||
embedNames := flags.MustStringSlice("pdfengines-embed-engines")
|
||||
embedMetadataNames := flags.MustStringSlice("pdfengines-embed-metadata-engines")
|
||||
readBookmarksNames := flags.MustStringSlice("pdfengines-read-bookmarks-engines")
|
||||
writeBookmarksNames := flags.MustStringSlice("pdfengines-write-bookmarks-engines")
|
||||
watermarkNames := flags.MustStringSlice("pdfengines-watermark-engines")
|
||||
@@ -162,6 +165,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
|
||||
mod.embedNames = embedNames
|
||||
}
|
||||
|
||||
mod.embedMetadataNames = defaultNames
|
||||
if len(embedMetadataNames) > 0 {
|
||||
mod.embedMetadataNames = embedMetadataNames
|
||||
}
|
||||
|
||||
mod.readBookmarksNames = defaultNames
|
||||
if len(readBookmarksNames) > 0 {
|
||||
mod.readBookmarksNames = readBookmarksNames
|
||||
@@ -236,6 +244,7 @@ func (mod *PdfEngines) Validate() error {
|
||||
findNonExistingEngines(mod.writeMetadataNames)
|
||||
findNonExistingEngines(mod.encryptNames)
|
||||
findNonExistingEngines(mod.embedNames)
|
||||
findNonExistingEngines(mod.embedMetadataNames)
|
||||
findNonExistingEngines(mod.readBookmarksNames)
|
||||
findNonExistingEngines(mod.writeBookmarksNames)
|
||||
findNonExistingEngines(mod.watermarkNames)
|
||||
@@ -261,6 +270,7 @@ func (mod *PdfEngines) SystemMessages() []string {
|
||||
fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames, " ")),
|
||||
fmt.Sprintf("encrypt engines - %s", strings.Join(mod.encryptNames, " ")),
|
||||
fmt.Sprintf("embed engines - %s", strings.Join(mod.embedNames, " ")),
|
||||
fmt.Sprintf("embed metadata engines - %s", strings.Join(mod.embedMetadataNames, " ")),
|
||||
fmt.Sprintf("read bookmarks engines - %s", strings.Join(mod.readBookmarksNames, " ")),
|
||||
fmt.Sprintf("write bookmarks engines - %s", strings.Join(mod.writeBookmarksNames, " ")),
|
||||
fmt.Sprintf("watermark engines - %s", strings.Join(mod.watermarkNames, " ")),
|
||||
@@ -294,6 +304,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
|
||||
engines(mod.writeMetadataNames),
|
||||
engines(mod.encryptNames),
|
||||
engines(mod.embedNames),
|
||||
engines(mod.embedMetadataNames),
|
||||
engines(mod.readBookmarksNames),
|
||||
engines(mod.writeBookmarksNames),
|
||||
engines(mod.watermarkNames),
|
||||
|
||||
@@ -443,6 +443,30 @@ func FormDataPdfEmbeds(form *api.FormData) []string {
|
||||
return embedPaths
|
||||
}
|
||||
|
||||
// FormDataPdfEmbedsMetadata extracts embeds metadata from form data.
|
||||
// The "embedsMetadata" field is a JSON string keyed by filename.
|
||||
func FormDataPdfEmbedsMetadata(form *api.FormData) map[string]map[string]string {
|
||||
var metadata map[string]map[string]string
|
||||
form.EmbedsMetadata(&metadata)
|
||||
return metadata
|
||||
}
|
||||
|
||||
// EmbedFilesMetadataStub sets metadata on embedded files in PDFs.
|
||||
func EmbedFilesMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metadata map[string]map[string]string, inputPaths []string) error {
|
||||
if len(metadata) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, inputPath := range inputPaths {
|
||||
err := engine.EmbedFilesMetadata(ctx, ctx.Log(), metadata, inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set embeds metadata on PDF '%s': %w", inputPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormDataPdfEncrypt extracts encryption parameters from form data.
|
||||
func FormDataPdfEncrypt(form *api.FormData) (userPassword, ownerPassword string) {
|
||||
form.String("userPassword", &userPassword, "")
|
||||
@@ -638,6 +662,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
stamp := FormDataPdfStamp(form, false)
|
||||
stampFile := FormDataPdfStampFile(form)
|
||||
angle, rotatePages := FormDataPdfRotate(form, false)
|
||||
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
||||
|
||||
var inputPaths []string
|
||||
var flatten bool
|
||||
@@ -754,6 +779,11 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
return fmt.Errorf("embed files into PDFs: %w", err)
|
||||
}
|
||||
|
||||
err = EmbedFilesMetadataStub(ctx, engine, embedsMetadata, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set embeds metadata: %w", err)
|
||||
}
|
||||
|
||||
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||
@@ -789,6 +819,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
stamp := FormDataPdfStamp(form, false)
|
||||
stampFile := FormDataPdfStampFile(form)
|
||||
angle, rotatePages := FormDataPdfRotate(form, false)
|
||||
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
||||
|
||||
var inputPaths []string
|
||||
var flatten bool
|
||||
@@ -856,6 +887,11 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
return fmt.Errorf("embed files into PDFs: %w", err)
|
||||
}
|
||||
|
||||
err = EmbedFilesMetadataStub(ctx, engine, embedsMetadata, convertOutputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set embeds metadata: %w", err)
|
||||
}
|
||||
|
||||
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||
@@ -1180,6 +1216,7 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
|
||||
form := ctx.FormData()
|
||||
embedPaths := FormDataPdfEmbeds(form)
|
||||
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
||||
|
||||
var inputPaths []string
|
||||
err := form.
|
||||
@@ -1193,6 +1230,11 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
return fmt.Errorf("embed files into PDFs: %w", err)
|
||||
}
|
||||
|
||||
err = EmbedFilesMetadataStub(ctx, engine, embedsMetadata, inputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set embeds metadata: %w", err)
|
||||
}
|
||||
|
||||
err = ctx.AddOutputPaths(inputPaths...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add output paths: %w", err)
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
// Package pdftk provides an implementation of the gotenberg.PdfEngine
|
||||
// interface using the PDFtk command-line tool. This package allows for:
|
||||
// Package pdftk implements gotenberg.PdfEngine using the PDFtk command-line
|
||||
// tool. Merges and splits PDF files.
|
||||
//
|
||||
// 1. The merging of PDF files.
|
||||
// 2. The splitting of PDF files.
|
||||
// Requires the PDFTK_BIN_PATH environment variable.
|
||||
//
|
||||
// The path to the PDFtk binary must be specified using the PDFTK_BIN_PATH
|
||||
// environment variable.
|
||||
//
|
||||
// See: https://gitlab.com/pdftk-java/pdftk.
|
||||
// See https://gitlab.com/pdftk-java/pdftk.
|
||||
package pdftk
|
||||
|
||||
@@ -495,6 +495,11 @@ func (engine *PdfTk) Rotate(ctx context.Context, logger *slog.Logger, inputPath
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmbedFilesMetadata is not available in this implementation.
|
||||
func (engine *PdfTk) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
|
||||
return fmt.Errorf("set embeds metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*PdfTk)(nil)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Package prometheus provides a module which collects metrics and exposes them
|
||||
// via an HTTP route.
|
||||
// Package prometheus collects metrics and exposes them via an HTTP route.
|
||||
//
|
||||
// See: https://prometheus.io/.
|
||||
// See https://prometheus.io/.
|
||||
package prometheus
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
// Package qpdf provides an implementation of the gotenberg.PdfEngine
|
||||
// interface using the QPDF command-line tool. This package allows for:
|
||||
// Package qpdf implements gotenberg.PdfEngine using the QPDF command-line
|
||||
// tool. Merges, splits, and flattens PDF files.
|
||||
//
|
||||
// 1. The merging of PDF files.
|
||||
// 2. The splitting of PDF files.
|
||||
// 3. Flattening of PDF files
|
||||
// Requires the QPDF_BIN_PATH environment variable.
|
||||
//
|
||||
// The path to the QPDF binary must be specified using the QPDK_BIN_PATH
|
||||
// environment variable.
|
||||
//
|
||||
// See: https://github.com/qpdf/qpdf.
|
||||
// See https://github.com/qpdf/qpdf.
|
||||
package qpdf
|
||||
|
||||
@@ -3,12 +3,14 @@ package qpdf
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
@@ -348,6 +350,291 @@ func (engine *QPdf) EmbedFiles(ctx context.Context, logger *slog.Logger, filePat
|
||||
return err
|
||||
}
|
||||
|
||||
// EmbedFilesMetadata sets metadata on already-embedded files in a PDF using
|
||||
// QPDF's JSON manipulation. It sets /AFRelationship on Filespec objects,
|
||||
// /Subtype on EmbeddedFile streams, and ensures the Catalog /AF array
|
||||
// references the Filespec objects.
|
||||
func (engine *QPdf) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.EmbedFilesMetadata",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
if len(metadata) == 0 {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.DebugContext(ctx, fmt.Sprintf("setting embeds metadata on %s with QPDF", inputPath))
|
||||
|
||||
args := append([]string{inputPath}, engine.globalArgs...)
|
||||
args = append(args, "--newline-before-endstream", "--json-output")
|
||||
|
||||
output, err := engine.execCaptureOutput(ctx, args...)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("get PDF JSON with QPDF: %w", err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
objects, err := parsePdfObjects(output)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
catalogRef, catalogValue, filespecRefs, updateObjects := patchFilespecMetadata(logger, objects, metadata)
|
||||
if len(filespecRefs) == 0 {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
patchCatalogAF(catalogRef, catalogValue, filespecRefs, updateObjects)
|
||||
|
||||
err = engine.writeAndApplyUpdate(ctx, logger, inputPath, updateObjects)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// execCaptureOutput runs QPDF and returns its stdout. This uses
|
||||
// exec.CommandContext directly because gotenberg.Cmd does not support
|
||||
// capturing stdout (it only pipes to debug logs).
|
||||
func (engine *QPdf) execCaptureOutput(ctx context.Context, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, engine.binPath, args...) //nolint:gosec
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
return cmd.Output()
|
||||
}
|
||||
|
||||
// parsePdfObjects parses QPDF JSON v2 output and returns the objects map.
|
||||
func parsePdfObjects(output []byte) (map[string]json.RawMessage, error) {
|
||||
var pdfJSON struct {
|
||||
Qpdf []json.RawMessage `json:"qpdf"`
|
||||
}
|
||||
if err := json.Unmarshal(output, &pdfJSON); err != nil {
|
||||
return nil, fmt.Errorf("parse PDF JSON: %w", err)
|
||||
}
|
||||
if len(pdfJSON.Qpdf) < 2 {
|
||||
return nil, fmt.Errorf("unexpected QPDF JSON structure: expected at least 2 elements")
|
||||
}
|
||||
|
||||
var objects map[string]json.RawMessage
|
||||
if err := json.Unmarshal(pdfJSON.Qpdf[1], &objects); err != nil {
|
||||
return nil, fmt.Errorf("parse QPDF objects: %w", err)
|
||||
}
|
||||
|
||||
return objects, nil
|
||||
}
|
||||
|
||||
// patchFilespecMetadata walks QPDF objects to find Filespecs matching the
|
||||
// metadata keys. It sets /AFRelationship and /Subtype on matching objects
|
||||
// and returns the catalog reference, catalog value, filespec references,
|
||||
// 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) {
|
||||
updateObjects := make(map[string]any)
|
||||
var catalogRef string
|
||||
var catalogValue map[string]any
|
||||
var filespecRefs []string
|
||||
|
||||
for ref, raw := range objects {
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
valueRaw, hasValue := obj["value"]
|
||||
if !hasValue {
|
||||
continue
|
||||
}
|
||||
|
||||
var value map[string]any
|
||||
if err := json.Unmarshal(valueRaw, &value); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
typeVal, _ := value["/Type"].(string)
|
||||
|
||||
if typeVal == "/Catalog" {
|
||||
catalogRef = ref
|
||||
catalogValue = value
|
||||
}
|
||||
|
||||
if typeVal == "/Filespec" {
|
||||
uf, _ := value["/UF"].(string)
|
||||
if uf == "" {
|
||||
uf, _ = value["/F"].(string)
|
||||
}
|
||||
|
||||
cleanUf := stripQpdfStringPrefix(uf)
|
||||
|
||||
meta, exists := metadata[cleanUf]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
if rel, ok := meta["relationship"]; ok {
|
||||
value["/AFRelationship"] = "/" + rel
|
||||
}
|
||||
|
||||
if mimeType, ok := meta["mimeType"]; ok {
|
||||
if ef, ok := value["/EF"].(map[string]any); ok {
|
||||
efRef, _ := ef["/F"].(string)
|
||||
if efRef != "" {
|
||||
setStreamSubtype(logger, objects, updateObjects, efRef, mimeType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filespecRefs = append(filespecRefs, ref)
|
||||
updateObjects[ref] = map[string]any{"value": value}
|
||||
}
|
||||
}
|
||||
|
||||
return catalogRef, catalogValue, filespecRefs, updateObjects
|
||||
}
|
||||
|
||||
// patchCatalogAF ensures the Catalog /AF array references all filespec objects.
|
||||
func patchCatalogAF(catalogRef string, catalogValue map[string]any, filespecRefs []string, updateObjects map[string]any) {
|
||||
if catalogRef == "" || catalogValue == nil {
|
||||
return
|
||||
}
|
||||
|
||||
afSet := make(map[string]bool)
|
||||
existingAF, _ := catalogValue["/AF"].([]any)
|
||||
for _, r := range existingAF {
|
||||
if s, ok := r.(string); ok {
|
||||
afSet[s] = true
|
||||
}
|
||||
}
|
||||
for _, ref := range filespecRefs {
|
||||
// Object references in values use "9 0 R" format,
|
||||
// not the "obj:9 0 R" key format.
|
||||
valRef := strings.TrimPrefix(ref, "obj:")
|
||||
if !afSet[valRef] {
|
||||
existingAF = append(existingAF, valRef)
|
||||
}
|
||||
}
|
||||
catalogValue["/AF"] = existingAF
|
||||
updateObjects[catalogRef] = map[string]any{"value": catalogValue}
|
||||
}
|
||||
|
||||
// writeAndApplyUpdate marshals the update objects as QPDF JSON v2, writes
|
||||
// them to a temp file, and applies the update via --update-from-json.
|
||||
func (engine *QPdf) writeAndApplyUpdate(ctx context.Context, logger *slog.Logger, inputPath string, updateObjects map[string]any) error {
|
||||
updateJSON := map[string]any{
|
||||
"qpdf": []any{
|
||||
map[string]any{
|
||||
"jsonversion": 2,
|
||||
"pushedinheritedpageresources": false,
|
||||
"calledgetallpages": false,
|
||||
"maxobjectid": 0,
|
||||
},
|
||||
updateObjects,
|
||||
},
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(updateJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal update JSON: %w", err)
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp(filepath.Dir(inputPath), "qpdf-embeds-metadata-*.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file for update JSON: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
if _, err := tmpFile.Write(jsonBytes); err != nil {
|
||||
tmpFile.Close()
|
||||
return fmt.Errorf("write update JSON: %w", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
return fmt.Errorf("close temp file: %w", err)
|
||||
}
|
||||
|
||||
updateArgs := make([]string, 0, 5+len(engine.globalArgs))
|
||||
updateArgs = append(updateArgs, inputPath)
|
||||
updateArgs = append(updateArgs, engine.globalArgs...)
|
||||
updateArgs = append(updateArgs, "--newline-before-endstream")
|
||||
updateArgs = append(updateArgs, "--update-from-json="+tmpFile.Name())
|
||||
updateArgs = append(updateArgs, "--replace-input")
|
||||
|
||||
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, updateArgs...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create command for JSON update: %w", err)
|
||||
}
|
||||
|
||||
_, err = cmd.Exec()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update embeds metadata with QPDF: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setStreamSubtype finds a stream object by reference and sets the /Subtype
|
||||
// key in its dict.
|
||||
func setStreamSubtype(logger *slog.Logger, objects map[string]json.RawMessage, updateObjects map[string]any, ref, mimeType string) {
|
||||
objKey := ref
|
||||
if !strings.HasPrefix(objKey, "obj:") {
|
||||
objKey = "obj:" + objKey
|
||||
}
|
||||
raw, ok := objects[objKey]
|
||||
if !ok {
|
||||
logger.Warn(fmt.Sprintf("set stream subtype on %s: object not found", ref))
|
||||
return
|
||||
}
|
||||
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
logger.Warn(fmt.Sprintf("set stream subtype on %s: unmarshal object: %s", ref, err))
|
||||
return
|
||||
}
|
||||
|
||||
streamRaw, ok := obj["stream"]
|
||||
if !ok {
|
||||
logger.Warn(fmt.Sprintf("set stream subtype on %s: no stream key", ref))
|
||||
return
|
||||
}
|
||||
|
||||
var stream map[string]any
|
||||
if err := json.Unmarshal(streamRaw, &stream); err != nil {
|
||||
logger.Warn(fmt.Sprintf("set stream subtype on %s: unmarshal stream: %s", ref, err))
|
||||
return
|
||||
}
|
||||
|
||||
dict, ok := stream["dict"].(map[string]any)
|
||||
if !ok {
|
||||
logger.Warn(fmt.Sprintf("set stream subtype on %s: stream dict is not a map", ref))
|
||||
return
|
||||
}
|
||||
|
||||
// QPDF JSON uses literal name syntax; it handles PDF name
|
||||
// encoding internally when writing the binary PDF.
|
||||
dict["/Subtype"] = "/" + mimeType
|
||||
stream["dict"] = dict
|
||||
updateObjects[objKey] = map[string]any{"stream": stream}
|
||||
}
|
||||
|
||||
// stripQpdfStringPrefix removes the type prefix that QPDF adds to JSON
|
||||
// string values. Known prefixes: "u:" (Unicode), "b:" (binary), "e:" (encoded).
|
||||
func stripQpdfStringPrefix(s string) string {
|
||||
for _, prefix := range []string{"u:", "b:", "e:"} {
|
||||
if strings.HasPrefix(s, prefix) {
|
||||
return s[len(prefix):]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Watermark is not available in this implementation.
|
||||
func (engine *QPdf) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Watermark",
|
||||
|
||||
271
pkg/modules/qpdf/qpdf_test.go
Normal file
271
pkg/modules/qpdf/qpdf_test.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package qpdf
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStripQpdfStringPrefix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"unicode prefix", "u:factur-x.xml", "factur-x.xml"},
|
||||
{"binary prefix", "b:binary.bin", "binary.bin"},
|
||||
{"encoded prefix", "e:encoded.txt", "encoded.txt"},
|
||||
{"no prefix", "plain.xml", "plain.xml"},
|
||||
{"empty string", "", ""},
|
||||
{"prefix only", "u:", ""},
|
||||
{"colon in value", "u:file:name.xml", "file:name.xml"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := stripQpdfStringPrefix(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("stripQpdfStringPrefix(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePdfObjects(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantKeys []string
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "valid QPDF JSON v2",
|
||||
input: `{"qpdf":[{"jsonversion":2},{"obj:1 0 R":{"value":{"/Type":"/Catalog"}}}]}`,
|
||||
wantKeys: []string{"obj:1 0 R"},
|
||||
},
|
||||
{
|
||||
name: "invalid JSON",
|
||||
input: `not json`,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "empty qpdf array",
|
||||
input: `{"qpdf":[]}`,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "only header element",
|
||||
input: `{"qpdf":[{"jsonversion":2}]}`,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "multiple objects",
|
||||
input: `{"qpdf":[{},{"obj:1 0 R":{"value":{}},"obj:2 0 R":{"value":{}}}]}`,
|
||||
wantKeys: []string{"obj:1 0 R", "obj:2 0 R"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
objects, err := parsePdfObjects([]byte(tt.input))
|
||||
if tt.wantError {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, key := range tt.wantKeys {
|
||||
if _, ok := objects[key]; !ok {
|
||||
t.Errorf("expected key %q in objects", key)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchFilespecMetadata(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
|
||||
t.Run("sets AFRelationship on matching Filespec", func(t *testing.T) {
|
||||
objects := map[string]json.RawMessage{
|
||||
"obj:1 0 R": json.RawMessage(`{"value":{"/Type":"/Catalog"}}`),
|
||||
"obj:2 0 R": json.RawMessage(`{"value":{"/Type":"/Filespec","/UF":"u:factur-x.xml"}}`),
|
||||
}
|
||||
metadata := map[string]map[string]string{
|
||||
"factur-x.xml": {"relationship": "Data"},
|
||||
}
|
||||
|
||||
catalogRef, _, filespecRefs, updateObjects := patchFilespecMetadata(logger, objects, metadata)
|
||||
|
||||
if catalogRef != "obj:1 0 R" {
|
||||
t.Errorf("catalogRef = %q, want %q", catalogRef, "obj:1 0 R")
|
||||
}
|
||||
if len(filespecRefs) != 1 || filespecRefs[0] != "obj:2 0 R" {
|
||||
t.Errorf("filespecRefs = %v, want [obj:2 0 R]", filespecRefs)
|
||||
}
|
||||
updated, ok := updateObjects["obj:2 0 R"]
|
||||
if !ok {
|
||||
t.Fatal("expected obj:2 0 R in updateObjects")
|
||||
}
|
||||
value := updated.(map[string]any)["value"].(map[string]any)
|
||||
if value["/AFRelationship"] != "/Data" {
|
||||
t.Errorf("/AFRelationship = %v, want /Data", value["/AFRelationship"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("skips Filespec with no matching metadata", func(t *testing.T) {
|
||||
objects := map[string]json.RawMessage{
|
||||
"obj:1 0 R": json.RawMessage(`{"value":{"/Type":"/Filespec","/UF":"u:other.xml"}}`),
|
||||
}
|
||||
metadata := map[string]map[string]string{
|
||||
"factur-x.xml": {"relationship": "Data"},
|
||||
}
|
||||
|
||||
_, _, filespecRefs, _ := patchFilespecMetadata(logger, objects, metadata)
|
||||
if len(filespecRefs) != 0 {
|
||||
t.Errorf("filespecRefs = %v, want empty", filespecRefs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("falls back to /F when /UF is absent", func(t *testing.T) {
|
||||
objects := map[string]json.RawMessage{
|
||||
"obj:1 0 R": json.RawMessage(`{"value":{"/Type":"/Filespec","/F":"u:factur-x.xml"}}`),
|
||||
}
|
||||
metadata := map[string]map[string]string{
|
||||
"factur-x.xml": {"relationship": "Alternative"},
|
||||
}
|
||||
|
||||
_, _, filespecRefs, updateObjects := patchFilespecMetadata(logger, objects, metadata)
|
||||
if len(filespecRefs) != 1 {
|
||||
t.Fatalf("filespecRefs = %v, want 1 entry", filespecRefs)
|
||||
}
|
||||
value := updateObjects["obj:1 0 R"].(map[string]any)["value"].(map[string]any)
|
||||
if value["/AFRelationship"] != "/Alternative" {
|
||||
t.Errorf("/AFRelationship = %v, want /Alternative", value["/AFRelationship"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sets stream Subtype via EF reference", func(t *testing.T) {
|
||||
objects := map[string]json.RawMessage{
|
||||
"obj:2 0 R": json.RawMessage(`{"value":{"/Type":"/Filespec","/UF":"u:factur-x.xml","/EF":{"/F":"3 0 R"}}}`),
|
||||
"obj:3 0 R": json.RawMessage(`{"stream":{"dict":{"/Type":"/EmbeddedFile"}}}`),
|
||||
}
|
||||
metadata := map[string]map[string]string{
|
||||
"factur-x.xml": {"mimeType": "text/xml"},
|
||||
}
|
||||
|
||||
_, _, _, updateObjects := patchFilespecMetadata(logger, objects, metadata)
|
||||
streamObj, ok := updateObjects["obj:3 0 R"]
|
||||
if !ok {
|
||||
t.Fatal("expected obj:3 0 R in updateObjects")
|
||||
}
|
||||
stream := streamObj.(map[string]any)["stream"].(map[string]any)
|
||||
dict := stream["dict"].(map[string]any)
|
||||
if dict["/Subtype"] != "/text/xml" {
|
||||
t.Errorf("/Subtype = %v, want /text/xml", dict["/Subtype"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPatchCatalogAF(t *testing.T) {
|
||||
t.Run("adds filespec refs to AF array", func(t *testing.T) {
|
||||
catalogValue := map[string]any{"/Type": "/Catalog"}
|
||||
updateObjects := make(map[string]any)
|
||||
|
||||
patchCatalogAF("obj:1 0 R", catalogValue, []string{"obj:2 0 R", "obj:3 0 R"}, updateObjects)
|
||||
|
||||
af, ok := catalogValue["/AF"].([]any)
|
||||
if !ok {
|
||||
t.Fatal("expected /AF to be []any")
|
||||
}
|
||||
if len(af) != 2 {
|
||||
t.Fatalf("/AF has %d entries, want 2", len(af))
|
||||
}
|
||||
if af[0] != "2 0 R" || af[1] != "3 0 R" {
|
||||
t.Errorf("/AF = %v, want [2 0 R, 3 0 R]", af)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("does not duplicate existing refs", func(t *testing.T) {
|
||||
catalogValue := map[string]any{
|
||||
"/Type": "/Catalog",
|
||||
"/AF": []any{"2 0 R"},
|
||||
}
|
||||
updateObjects := make(map[string]any)
|
||||
|
||||
patchCatalogAF("obj:1 0 R", catalogValue, []string{"obj:2 0 R", "obj:3 0 R"}, updateObjects)
|
||||
|
||||
af := catalogValue["/AF"].([]any)
|
||||
if len(af) != 2 {
|
||||
t.Fatalf("/AF has %d entries, want 2", len(af))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no-op when catalogRef is empty", func(t *testing.T) {
|
||||
updateObjects := make(map[string]any)
|
||||
patchCatalogAF("", nil, []string{"obj:2 0 R"}, updateObjects)
|
||||
if len(updateObjects) != 0 {
|
||||
t.Error("expected no updates for empty catalogRef")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetStreamSubtype(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
|
||||
t.Run("sets Subtype in stream dict", func(t *testing.T) {
|
||||
objects := map[string]json.RawMessage{
|
||||
"obj:3 0 R": json.RawMessage(`{"stream":{"dict":{"/Type":"/EmbeddedFile"}}}`),
|
||||
}
|
||||
updateObjects := make(map[string]any)
|
||||
|
||||
setStreamSubtype(logger, objects, updateObjects, "obj:3 0 R", "text/xml")
|
||||
|
||||
streamObj := updateObjects["obj:3 0 R"].(map[string]any)["stream"].(map[string]any)
|
||||
dict := streamObj["dict"].(map[string]any)
|
||||
if dict["/Subtype"] != "/text/xml" {
|
||||
t.Errorf("/Subtype = %v, want /text/xml", dict["/Subtype"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("auto-adds obj: prefix to ref", func(t *testing.T) {
|
||||
objects := map[string]json.RawMessage{
|
||||
"obj:5 0 R": json.RawMessage(`{"stream":{"dict":{}}}`),
|
||||
}
|
||||
updateObjects := make(map[string]any)
|
||||
|
||||
setStreamSubtype(logger, objects, updateObjects, "5 0 R", "application/pdf")
|
||||
|
||||
if _, ok := updateObjects["obj:5 0 R"]; !ok {
|
||||
t.Error("expected obj:5 0 R in updateObjects")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("warns on missing object", func(t *testing.T) {
|
||||
objects := map[string]json.RawMessage{}
|
||||
updateObjects := make(map[string]any)
|
||||
|
||||
setStreamSubtype(logger, objects, updateObjects, "obj:99 0 R", "text/xml")
|
||||
|
||||
if len(updateObjects) != 0 {
|
||||
t.Error("expected no updates for missing object")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("warns on object without stream key", func(t *testing.T) {
|
||||
objects := map[string]json.RawMessage{
|
||||
"obj:3 0 R": json.RawMessage(`{"value":{"/Type":"/Page"}}`),
|
||||
}
|
||||
updateObjects := make(map[string]any)
|
||||
|
||||
setStreamSubtype(logger, objects, updateObjects, "obj:3 0 R", "text/xml")
|
||||
|
||||
if len(updateObjects) != 0 {
|
||||
t.Error("expected no updates for non-stream object")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -39,6 +39,11 @@ type client struct {
|
||||
func (c client) send(ctx context.Context, body io.Reader, headers map[string]string, errored bool) error {
|
||||
url := c.url
|
||||
if errored {
|
||||
if c.errorUrl == "" {
|
||||
// No error URL provided; error details will be sent
|
||||
// via the events URL instead.
|
||||
return nil
|
||||
}
|
||||
url = c.errorUrl
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// Package webhook provides a module which adds a middleware for uploading
|
||||
// output files to any destination in an asynchronous fashion.
|
||||
// Package webhook adds middleware for uploading output files to any destination
|
||||
// asynchronously.
|
||||
package webhook
|
||||
|
||||
@@ -105,30 +105,40 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
cancel := c.Get("cancel").(context.CancelFunc)
|
||||
|
||||
// Do we have a webhook error URL in case of... error?
|
||||
// Do we have a webhook error URL and/or an events URL?
|
||||
// At least one must be provided.
|
||||
webhookErrorUrl := c.Request().Header.Get("Gotenberg-Webhook-Error-Url")
|
||||
if webhookErrorUrl == "" {
|
||||
webhookEventsUrl := c.Request().Header.Get("Gotenberg-Webhook-Events-Url")
|
||||
|
||||
if webhookErrorUrl == "" && webhookEventsUrl == "" {
|
||||
return api.WrapError(
|
||||
errors.New("empty webhook error URL"),
|
||||
api.NewSentinelHttpError(http.StatusBadRequest, "Invalid 'Gotenberg-Webhook-Error-Url' header: empty value or header not provided"),
|
||||
errors.New("empty webhook error URL and events URL"),
|
||||
api.NewSentinelHttpError(http.StatusBadRequest, "At least one of 'Gotenberg-Webhook-Error-Url' or 'Gotenberg-Webhook-Events-Url' headers must be provided"),
|
||||
)
|
||||
}
|
||||
|
||||
if webhookErrorUrl != "" {
|
||||
ctx.Log().Warn("'Gotenberg-Webhook-Error-Url' header is deprecated, use 'Gotenberg-Webhook-Events-Url' instead")
|
||||
}
|
||||
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
return errors.New("context has no deadline")
|
||||
}
|
||||
|
||||
// Let's check if the webhook URLs are acceptable according to our
|
||||
// allowed/denied lists.
|
||||
err := gotenberg.FilterDeadline(w.allowList, w.denyList, webhookUrl, deadline)
|
||||
// allowed/denied lists, and against the IP-based outbound URL
|
||||
// guard. See [gotenberg.FilterOutboundURL].
|
||||
err := gotenberg.FilterOutboundURL(ctx, webhookUrl, w.allowList, w.denyList, deadline)
|
||||
if err != nil {
|
||||
return fmt.Errorf("filter webhook URL: %w", err)
|
||||
}
|
||||
|
||||
err = gotenberg.FilterDeadline(w.errorAllowList, w.errorDenyList, webhookErrorUrl, deadline)
|
||||
if err != nil {
|
||||
return fmt.Errorf("filter webhook error URL: %w", err)
|
||||
if webhookErrorUrl != "" {
|
||||
err = gotenberg.FilterOutboundURL(ctx, webhookErrorUrl, w.errorAllowList, w.errorDenyList, deadline)
|
||||
if err != nil {
|
||||
return fmt.Errorf("filter webhook error URL: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Let's check the HTTP methods for calling the webhook URLs.
|
||||
@@ -164,9 +174,12 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
||||
return fmt.Errorf("get method to use for webhook: %w", err)
|
||||
}
|
||||
|
||||
webhookErrorMethod, err := methodFromHeader("Gotenberg-Webhook-Error-Method")
|
||||
if err != nil {
|
||||
return fmt.Errorf("get method to use for webhook error: %w", err)
|
||||
var webhookErrorMethod string
|
||||
if webhookErrorUrl != "" {
|
||||
webhookErrorMethod, err = methodFromHeader("Gotenberg-Webhook-Error-Method")
|
||||
if err != nil {
|
||||
return fmt.Errorf("get method to use for webhook error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// What about extra HTTP headers?
|
||||
@@ -183,10 +196,9 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
||||
}
|
||||
}
|
||||
|
||||
// What about the events URL?
|
||||
webhookEventsUrl := c.Request().Header.Get("Gotenberg-Webhook-Events-Url")
|
||||
// Filter the events URL if provided.
|
||||
if webhookEventsUrl != "" {
|
||||
err = gotenberg.FilterDeadline(w.allowList, w.denyList, webhookEventsUrl, deadline)
|
||||
err = gotenberg.FilterOutboundURL(ctx, webhookEventsUrl, w.allowList, w.denyList, deadline)
|
||||
if err != nil {
|
||||
return fmt.Errorf("filter webhook events URL: %w", err)
|
||||
}
|
||||
@@ -208,9 +220,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
||||
startTime: startTime,
|
||||
|
||||
client: &retryablehttp.Client{
|
||||
HTTPClient: &http.Client{
|
||||
Timeout: w.clientTimeout,
|
||||
},
|
||||
HTTPClient: gotenberg.NewOutboundHttpClient(w.clientTimeout, w.allowList, w.denyList),
|
||||
RetryMax: w.maxRetry,
|
||||
RetryWaitMin: w.retryMinWait,
|
||||
RetryWaitMax: w.retryMaxWait,
|
||||
|
||||
@@ -39,10 +39,20 @@ func (w *Webhook) Descriptor() gotenberg.ModuleDescriptor {
|
||||
fs := flag.NewFlagSet("webhook", flag.ExitOnError)
|
||||
fs.Bool("webhook-enable-sync-mode", false, "Enable synchronous mode for the webhook feature")
|
||||
fs.StringSlice("webhook-allow-list", []string{}, "Set the allowed 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.StringSlice("webhook-deny-list", []string{`^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)`}, "Set the denied URLs for the webhook feature using regular expressions - supports multiple values")
|
||||
fs.Int("webhook-max-retry", 4, "Set the maximum number of retries for the webhook feature")
|
||||
|
||||
// Deprecated flags.
|
||||
fs.StringSlice("webhook-error-allow-list", []string{}, "Set the allowed URLs in case of an error for the webhook feature using regular expressions - supports multiple values")
|
||||
fs.StringSlice("webhook-error-deny-list", []string{}, "Set the denied URLs in case of an error for the webhook feature using regular expressions - supports multiple values")
|
||||
fs.Int("webhook-max-retry", 4, "Set the maximum number of retries for the webhook feature")
|
||||
err := fs.MarkDeprecated("webhook-error-allow-list", "use --webhook-allow-list instead")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = fs.MarkDeprecated("webhook-error-deny-list", "use --webhook-deny-list instead")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fs.Duration("webhook-retry-min-wait", time.Duration(1)*time.Second, "Set the minimum duration to wait before trying to call the webhook again")
|
||||
fs.Duration("webhook-retry-max-wait", time.Duration(30)*time.Second, "Set the maximum duration to wait before trying to call the webhook again")
|
||||
fs.Duration("webhook-client-timeout", time.Duration(30)*time.Second, "Set the time limit for requests to the webhook")
|
||||
@@ -60,8 +70,8 @@ func (w *Webhook) Provision(ctx *gotenberg.Context) error {
|
||||
w.enableSyncMode = flags.MustBool("webhook-enable-sync-mode")
|
||||
w.allowList = flags.MustRegexpSlice("webhook-allow-list")
|
||||
w.denyList = flags.MustRegexpSlice("webhook-deny-list")
|
||||
w.errorAllowList = flags.MustRegexpSlice("webhook-error-allow-list")
|
||||
w.errorDenyList = flags.MustRegexpSlice("webhook-error-deny-list")
|
||||
w.errorAllowList = flags.MustDeprecatedRegexpSlice("webhook-error-allow-list", "webhook-allow-list")
|
||||
w.errorDenyList = flags.MustDeprecatedRegexpSlice("webhook-error-deny-list", "webhook-deny-list")
|
||||
w.maxRetry = flags.MustInt("webhook-max-retry")
|
||||
w.retryMinWait = flags.MustDuration("webhook-retry-min-wait")
|
||||
w.retryMaxWait = flags.MustDuration("webhook-retry-max-wait")
|
||||
|
||||
3
pkg/standard/chromium/doc.go
Normal file
3
pkg/standard/chromium/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package chromium imports modules for the Chromium-only variant (no
|
||||
// LibreOffice).
|
||||
package chromium
|
||||
14
pkg/standard/chromium/imports.go
Normal file
14
pkg/standard/chromium/imports.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
// Gotenberg modules (Chromium variant — no LibreOffice).
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/chromium"
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/exiftool"
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/pdfcpu"
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/pdftk"
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/prometheus"
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/qpdf"
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/webhook"
|
||||
)
|
||||
@@ -1,2 +1,2 @@
|
||||
// Package standard imports the application's default modules.
|
||||
// Package standard imports the default modules.
|
||||
package standard
|
||||
|
||||
3
pkg/standard/libreoffice/doc.go
Normal file
3
pkg/standard/libreoffice/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package libreoffice imports modules for the LibreOffice-only variant (no
|
||||
// Chromium).
|
||||
package libreoffice
|
||||
16
pkg/standard/libreoffice/imports.go
Normal file
16
pkg/standard/libreoffice/imports.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package libreoffice
|
||||
|
||||
import (
|
||||
// Gotenberg modules (LibreOffice variant — no Chromium).
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/exiftool"
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice"
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/pdfengine"
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/pdfcpu"
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/pdftk"
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/prometheus"
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/qpdf"
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/webhook"
|
||||
)
|
||||
@@ -1,78 +0,0 @@
|
||||
# Integration Tests
|
||||
|
||||
- **Framework:** Gherkin (BDD) via [Godog](https://github.com/cucumber/godog), with `testcontainers-go` for Docker orchestration.
|
||||
- **Feature files:** `test/integration/features/*.feature` — one file per endpoint or capability.
|
||||
- **Test infrastructure:** `test/integration/scenario/` — Go step definitions, container management, HTTP helpers, PDF validation.
|
||||
- **Entry point:** `test/integration/main_test.go` (build tag: `integration`).
|
||||
- **Test data:** `test/integration/testdata/`
|
||||
|
||||
## How It Works
|
||||
|
||||
Each scenario spins up a fresh Gotenberg Docker container via testcontainers. The step definitions in `scenario/scenario.go` map Gherkin steps to Go functions. An additional `gotenberg/integration-tools` container provides PDF validation tools (`verapdf`, `pdfinfo`, `pdftotext`).
|
||||
|
||||
**Important:** Integration tests require a Docker image. Run `make build` before `make test-integration`.
|
||||
|
||||
## Selective Test Runs
|
||||
|
||||
Use the `TAGS` variable to run only relevant scenarios:
|
||||
|
||||
```bash
|
||||
make test-integration TAGS=health
|
||||
make test-integration TAGS=chromium-convert-html
|
||||
make test-integration TAGS="merge,split"
|
||||
```
|
||||
|
||||
Available tags: `chromium`, `chromium-concurrent`, `chromium-convert-html`, `chromium-convert-markdown`, `chromium-convert-url`, `chromium-screenshot-html`, `chromium-screenshot-markdown`, `chromium-screenshot-url`, `debug`, `health`, `libreoffice`, `libreoffice-convert`, `output-filename`, `pdfengines`, `pdfengines-convert`, `pdfengines-embed`, `embed`, `pdfengines-encrypt`, `encrypt`, `pdfengines-flatten`, `flatten`, `pdfengines-merge`, `merge`, `pdfengines-metadata`, `metadata`, `pdfengines-split`, `split`, `pdfengines-watermark`, `watermark`, `pdfengines-stamp`, `stamp`, `pdfengines-bookmarks`, `bookmarks`, `pdfengines-rotate`, `rotate`, `prometheus-metrics`, `root`, `version`, `webhook`, `download-from`.
|
||||
|
||||
Other useful flags:
|
||||
|
||||
```bash
|
||||
make test-integration NO_CONCURRENCY=true # Disable parallel scenarios
|
||||
make test-integration PLATFORM=linux/arm64 # Force a specific platform
|
||||
```
|
||||
|
||||
## Writing a New Integration Test
|
||||
|
||||
1. Create or update a `.feature` file in `test/integration/features/`.
|
||||
2. Tag it appropriately (e.g., `@chromium @chromium-convert-html`).
|
||||
3. If the feature requires new tag(s), add them to both the `TAGS` comment block in the `Makefile` and the "Available tags" list above.
|
||||
4. If you create a new step definition, add it to `scenario/scenario.go`, register it in `InitializeScenario`, and update the "Available Gherkin Steps" list below.
|
||||
5. Test data goes in `test/integration/testdata/`.
|
||||
|
||||
## Available Gherkin Steps
|
||||
|
||||
**Given (setup):**
|
||||
|
||||
- `I have a default Gotenberg container`
|
||||
- `I have a Gotenberg container with the following environment variable(s):` (table: key | value)
|
||||
- `I have a (webhook|static) server`
|
||||
|
||||
**When (action):**
|
||||
|
||||
- `I make a "(GET|HEAD)" request to Gotenberg at the "<endpoint>" endpoint`
|
||||
- `I make a "(GET|HEAD)" request to Gotenberg at the "<endpoint>" endpoint with the following header(s):` (table: name | value)
|
||||
- `I make a "(POST)" request to Gotenberg at the "<endpoint>" endpoint with the following form data and header(s):` (table: name | value | kind — where kind is `file`, `field`, or `header`)
|
||||
- `I make <N> concurrent "(POST)" requests to Gotenberg at the "<endpoint>" endpoint with the following form data and header(s):` (same table format)
|
||||
- `I wait for the asynchronous request to the webhook`
|
||||
|
||||
**Then (assertions):**
|
||||
|
||||
- `the response status code should be <code>`
|
||||
- `the (response|webhook request) header "<name>" should be "<value>"`
|
||||
- `the (response|webhook request) cookie "<name>" should be "<value>"`
|
||||
- `the (response|webhook request) body should match string:` (docstring)
|
||||
- `the (response|webhook request) body should contain string:` (docstring)
|
||||
- `the (response|webhook request) body should match JSON:` (docstring — use `"ignore"` for dynamic values like timestamps)
|
||||
- `the webhook event should match JSON:` (docstring — use `"ignore"` for dynamic values; polls for up to 5s)
|
||||
- `there should be <N> PDF(s) in the (response|webhook request)`
|
||||
- `there should be the following file(s) in the (response|webhook request):` (table of filenames)
|
||||
- `the "<name>" PDF should have <N> page(s)`
|
||||
- `the "<name>" PDF (should|should NOT) be set to landscape orientation`
|
||||
- `the "<name>" PDF (should|should NOT) have the following content at page <N>:` (docstring)
|
||||
- `the (response|webhook request) PDF(s) should be valid "<standard>" with a tolerance of <N> failed rule(s)` (standards: `PDF/A-1b`, `PDF/A-2b`, `PDF/A-3b`, `PDF/UA-1`, `PDF/UA-2`)
|
||||
- `the (response|webhook request) PDF(s) (should|should NOT) be flatten`
|
||||
- `the (response|webhook request) PDF(s) (should|should NOT) be encrypted`
|
||||
- `the (response|webhook request) PDF(s) (should|should NOT) have the "<filename>" file embedded`
|
||||
- `the Gotenberg container (should|should NOT) log the following entries:` (table of log substrings)
|
||||
- `all concurrent response status codes should be <code>`
|
||||
- `all concurrent responses should have <N> PDF(s)`
|
||||
78
test/integration/README.md
Normal file
78
test/integration/README.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Integration Tests
|
||||
|
||||
Gherkin (BDD) via [Godog](https://github.com/cucumber/godog), with `testcontainers-go` for Docker orchestration.
|
||||
|
||||
- Feature files: `test/integration/features/*.feature`, one per endpoint or capability.
|
||||
- Step definitions: `test/integration/scenario/` (container management, HTTP helpers, PDF validation).
|
||||
- Entry point: `test/integration/main_test.go` (build tag: `integration`).
|
||||
- Test data: `test/integration/testdata/`.
|
||||
|
||||
Each scenario spins up a fresh Gotenberg Docker container via testcontainers. A separate `gotenberg/integration-tools` container provides PDF validation tools (`verapdf`, `pdfinfo`, `pdftotext`).
|
||||
|
||||
Run `make build` before `make test-integration`. Integration tests require a Docker image.
|
||||
|
||||
## Selective runs
|
||||
|
||||
```bash
|
||||
make test-integration TAGS=health
|
||||
make test-integration TAGS=chromium-convert-html
|
||||
make test-integration TAGS="merge,split"
|
||||
make test-integration NO_CONCURRENCY=true # disable parallel scenarios
|
||||
make test-integration PLATFORM=linux/arm64 # force a specific platform
|
||||
```
|
||||
|
||||
Available 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` |
|
||||
| LibreOffice | `libreoffice`, `libreoffice-convert` |
|
||||
| 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` |
|
||||
| Infra | `health`, `debug`, `root`, `version`, `output-filename`, `prometheus-metrics`, `webhook`, `download-from` |
|
||||
|
||||
## Writing a new test
|
||||
|
||||
1. Create or update a `.feature` file in `test/integration/features/`.
|
||||
2. Tag it appropriately (e.g., `@chromium @chromium-convert-html`).
|
||||
3. For new tags, add them to both the `TAGS` comment block in the `Makefile` and the table above.
|
||||
4. For new step definitions, add the function to `scenario/scenario.go`, register it in `InitializeScenario`, and add the step pattern to the step reference below.
|
||||
5. Test data goes in `test/integration/testdata/`.
|
||||
|
||||
## Step reference
|
||||
|
||||
### Given (setup)
|
||||
|
||||
- `I have a default Gotenberg container`
|
||||
- `I have a Gotenberg container with the following environment variable(s):` (table: key | value)
|
||||
- `I have a (webhook|static) server`
|
||||
|
||||
### When (action)
|
||||
|
||||
- `I make a "(GET|HEAD)" request to Gotenberg at the "<endpoint>" endpoint`
|
||||
- `I make a "(GET|HEAD)" request to Gotenberg at the "<endpoint>" endpoint with the following header(s):` (table: name | value)
|
||||
- `I make a "(POST)" request to Gotenberg at the "<endpoint>" endpoint with the following form data and header(s):` (table: name | value | kind, where kind is `file`, `field`, or `header`)
|
||||
- `I make <N> concurrent "(POST)" requests to Gotenberg at the "<endpoint>" endpoint with the following form data and header(s):` (same table format)
|
||||
- `I wait for the asynchronous request to the webhook`
|
||||
|
||||
### Then (assertions)
|
||||
|
||||
- `the response status code should be <code>`
|
||||
- `the (response|webhook request|file request|server request) header "<name>" should be "<value>"`
|
||||
- `the (response|webhook request|file request|server request) cookie "<name>" should be "<value>"`
|
||||
- `the (response|webhook request) body should match string:` (docstring)
|
||||
- `the (response|webhook request) body should contain string:` (docstring)
|
||||
- `the (response|webhook request) body should match JSON:` (docstring, use `"ignore"` for dynamic values)
|
||||
- `the webhook event should match JSON:` (docstring, use `"ignore"` for dynamic values; polls for up to 5s)
|
||||
- `there should be <N> PDF(s) in the (response|webhook request)`
|
||||
- `there should be the following file(s) in the (response|webhook request):` (table of filenames)
|
||||
- `the "<name>" PDF should have <N> page(s)`
|
||||
- `the "<name>" PDF (should|should NOT) be set to landscape orientation`
|
||||
- `the "<name>" PDF (should|should NOT) have the following content at page <N>:` (docstring)
|
||||
- `the (response|webhook request) PDF(s) should be valid "<standard>" with a tolerance of <N> failed rule(s)` (standards: `PDF/A-1b`, `PDF/A-2b`, `PDF/A-3b`, `PDF/UA-1`, `PDF/UA-2`)
|
||||
- `the (response|webhook request) PDF(s) (should|should NOT) be flatten`
|
||||
- `the (response|webhook request) PDF(s) (should|should NOT) be encrypted`
|
||||
- `the (response|webhook request) PDF(s) (should|should NOT) have the "<filename>" file embedded`
|
||||
- `the "<name>" PDF should have <N> image(s)`
|
||||
- `the Gotenberg container (should|should NOT) log the following entries:` (table of log substrings)
|
||||
- `all concurrent response status codes should be <code>`
|
||||
- `all concurrent responses should have <N> PDF(s)`
|
||||
@@ -1,2 +1,2 @@
|
||||
// Package integration contains everything related to integration testing.
|
||||
// Package integration contains the integration test suite.
|
||||
package integration
|
||||
|
||||
@@ -62,8 +62,8 @@ Feature: /debug
|
||||
"api-disable-health-check-route-telemetry": "true",
|
||||
"api-disable-root-route-telemetry": "true",
|
||||
"api-disable-version-route-telemetry": "true",
|
||||
"api-download-from-allow-list": "[]",
|
||||
"api-download-from-deny-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-max-retry": "4",
|
||||
"api-enable-basic-auth": "false",
|
||||
"api-enable-debug-route": "true",
|
||||
@@ -77,7 +77,7 @@ Feature: /debug
|
||||
"api-trace-header": "Gotenberg-Trace",
|
||||
"chromium-allow-file-access-from-files": "false",
|
||||
"chromium-allow-insecure-localhost": "false",
|
||||
"chromium-allow-list": "[]",
|
||||
"chromium-allow-list": "[.+]",
|
||||
"chromium-auto-start": "false",
|
||||
"chromium-clear-cache": "false",
|
||||
"chromium-clear-cookies": "false",
|
||||
@@ -124,9 +124,9 @@ Feature: /debug
|
||||
"prometheus-disable-route-telemetry": "true",
|
||||
"prometheus-namespace": "gotenberg",
|
||||
"prometheus-metrics-path": "/prometheus/metrics",
|
||||
"webhook-allow-list": "[]",
|
||||
"webhook-allow-list": "[.+]",
|
||||
"webhook-client-timeout": "30s",
|
||||
"webhook-deny-list": "[]",
|
||||
"webhook-deny-list": "[^https?://(10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.|169\\.254\\.|0\\.0\\.0\\.0|127\\.|localhost|\\[::1\\]|\\[fd)]",
|
||||
"webhook-disable": "false",
|
||||
"webhook-error-allow-list": "[]",
|
||||
"webhook-error-deny-list": "[]",
|
||||
@@ -194,8 +194,8 @@ Feature: /debug
|
||||
"api-disable-health-check-route-telemetry": "true",
|
||||
"api-disable-root-route-telemetry": "true",
|
||||
"api-disable-version-route-telemetry": "true",
|
||||
"api-download-from-allow-list": "[]",
|
||||
"api-download-from-deny-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-max-retry": "4",
|
||||
"api-enable-basic-auth": "false",
|
||||
"api-enable-debug-route": "true",
|
||||
@@ -209,7 +209,7 @@ Feature: /debug
|
||||
"api-trace-header": "Gotenberg-Trace",
|
||||
"chromium-allow-file-access-from-files": "false",
|
||||
"chromium-allow-insecure-localhost": "false",
|
||||
"chromium-allow-list": "[]",
|
||||
"chromium-allow-list": "[.+]",
|
||||
"chromium-auto-start": "false",
|
||||
"chromium-clear-cache": "false",
|
||||
"chromium-clear-cookies": "false",
|
||||
@@ -256,9 +256,9 @@ Feature: /debug
|
||||
"prometheus-disable-route-telemetry": "true",
|
||||
"prometheus-namespace": "gotenberg",
|
||||
"prometheus-metrics-path": "/prometheus/metrics",
|
||||
"webhook-allow-list": "[]",
|
||||
"webhook-allow-list": "[.+]",
|
||||
"webhook-client-timeout": "30s",
|
||||
"webhook-deny-list": "[]",
|
||||
"webhook-deny-list": "[^https?://(10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.|169\\.254\\.|0\\.0\\.0\\.0|127\\.|localhost|\\[::1\\]|\\[fd)]",
|
||||
"webhook-disable": "false",
|
||||
"webhook-error-allow-list": "[]",
|
||||
"webhook-error-deny-list": "[]",
|
||||
|
||||
@@ -17,6 +17,21 @@ Feature: /forms/pdfengines/embed
|
||||
Then the response PDF(s) should have the "embed_1.xml" file embedded
|
||||
Then the response PDF(s) should have the "embed_2.xml" file embedded
|
||||
|
||||
Scenario: POST /forms/pdfengines/embed with metadata
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/embed" endpoint with the following form data and header(s):
|
||||
| files | testdata/page_1.pdf | file |
|
||||
| embeds | testdata/embed_1.xml | file |
|
||||
| embeds | testdata/embed_2.xml | file |
|
||||
| embedsMetadata | {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"},"embed_2.xml":{"mimeType":"text/xml","relationship":"Alternative"}} | field |
|
||||
Then the response status code should be 200
|
||||
And the response header "Content-Type" should be "application/pdf"
|
||||
And there should be 1 PDF(s) in the response
|
||||
And the response PDF(s) should have the "embed_1.xml" file embedded
|
||||
And the response PDF(s) should have the "embed_1.xml" file embedded with relationship "Data"
|
||||
And the response PDF(s) should have the "embed_2.xml" file embedded
|
||||
And the response PDF(s) should have the "embed_2.xml" file embedded with relationship "Alternative"
|
||||
|
||||
@download-from
|
||||
Scenario: POST /forms/pdfengines/embed with (Download From)
|
||||
Given I have a default Gotenberg container
|
||||
|
||||
@@ -106,6 +106,43 @@ Feature: /forms/pdfengines/metadata/{write|read}
|
||||
Invalid form data: form field 'metadata' is invalid (got 'foo', resulting to unmarshal metadata: invalid character 'o' in literal false (expecting 'a'))
|
||||
"""
|
||||
|
||||
Scenario: POST /forms/pdfengines/metadata/write (Reject Newline-Injected Pseudo-Tag)
|
||||
# Regression: a newline in a metadata value would split go-exiftool's
|
||||
# stdin line and inject an arbitrary ExifTool pseudo-tag such as
|
||||
# -FileName=, -SymLink=, or -HardLink=, allowing arbitrary filesystem
|
||||
# writes as the container user. WriteMetadata now rejects values
|
||||
# containing control characters with HTTP 400.
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s):
|
||||
| files | testdata/page_1.pdf | file |
|
||||
| metadata | {"Title":"test\\n-FileName=/tmp/inject_proof"} | field |
|
||||
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 contain string:
|
||||
"""
|
||||
At least one PDF engine cannot process the requested metadata
|
||||
"""
|
||||
|
||||
Scenario: POST /forms/pdfengines/metadata/write (Reject Group-Prefixed Dangerous Tag)
|
||||
# Regression: ExifTool treats "System:FileName" identically to "FileName".
|
||||
# The dangerous-tag blocklist must strip group prefixes before comparing,
|
||||
# otherwise the attacker renames/moves files with a single HTTP request.
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s):
|
||||
| files | testdata/page_1.pdf | file |
|
||||
| metadata | {"System:FileName":"stolen.pdf","System:Directory":"/tmp","Author":"legit"} | 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
|
||||
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/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 body should contain string:
|
||||
"""
|
||||
"Author":"legit"
|
||||
"""
|
||||
|
||||
Scenario: POST /forms/pdfengines/metadata/read (Bad Request)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s):
|
||||
|
||||
@@ -86,3 +86,43 @@ Feature: Webhook
|
||||
"timestamp": "ignore"
|
||||
}
|
||||
"""
|
||||
|
||||
Scenario: Webhook Events URL Only (Success)
|
||||
Given I have a default Gotenberg container
|
||||
Given I have a webhook server
|
||||
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-Webhook-Url | http://host.docker.internal:%d/webhook | header |
|
||||
| Gotenberg-Webhook-Events-Url | http://host.docker.internal:%d/webhook/events | header |
|
||||
Then the response status code should be 204
|
||||
When I wait for the asynchronous request to the webhook
|
||||
Then the webhook request header "Content-Type" should be "application/pdf"
|
||||
Then there should be 1 PDF(s) in the webhook request
|
||||
Then the webhook event should match JSON:
|
||||
"""
|
||||
{
|
||||
"event": "webhook.success",
|
||||
"correlationId": "ignore",
|
||||
"timestamp": "ignore"
|
||||
}
|
||||
"""
|
||||
|
||||
Scenario: Webhook Events URL Only (Synchronous)
|
||||
Given I have a Gotenberg container with the following environment variable(s):
|
||||
| WEBHOOK_ENABLE_SYNC_MODE | true |
|
||||
Given I have a webhook server
|
||||
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-Webhook-Url | http://host.docker.internal:%d/webhook | header |
|
||||
| Gotenberg-Webhook-Events-Url | http://host.docker.internal:%d/webhook/events | header |
|
||||
Then the response status code should be 204
|
||||
Then the webhook request header "Content-Type" should be "application/pdf"
|
||||
Then there should be 1 PDF(s) in the webhook request
|
||||
Then the webhook event should match JSON:
|
||||
"""
|
||||
{
|
||||
"event": "webhook.success",
|
||||
"correlationId": "ignore",
|
||||
"timestamp": "ignore"
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/go-connections/nat"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/exec"
|
||||
"github.com/testcontainers/testcontainers-go/network"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
)
|
||||
@@ -26,10 +26,53 @@ func (n *noopLogger) Printf(format string, v ...any) {
|
||||
// NOOP
|
||||
}
|
||||
|
||||
// integrationAllowList is the default allow-list pattern injected into
|
||||
// every Gotenberg container started by the integration tests. The outbound
|
||||
// URL guard introduced for SSRF protection rejects URLs whose host
|
||||
// resolves to a non-public IP, which would block:
|
||||
//
|
||||
// - host.docker.internal (Docker host gateway, RFC1918)
|
||||
// - The static helper server running inside the test network
|
||||
// - file:// URIs created in /tmp by the API context
|
||||
//
|
||||
// Setting the allow-list to a permissive pattern flips the URL guard into
|
||||
// "allow-list match bypasses the IP check" mode for every URL the tests
|
||||
// touch. Operator-supplied deny-lists still apply, so deny-list scenarios
|
||||
// keep working. Test scenarios that exercise allow-list semantics
|
||||
// explicitly override this default in their environment table.
|
||||
//
|
||||
// Production operators wanting a similar bypass for trusted internal
|
||||
// destinations should set their own --*-allow-list with a tighter regex
|
||||
// (for example ^https?://internal\.svc(:|/|$)).
|
||||
const integrationAllowList = `.+`
|
||||
|
||||
// applyDefaultEnv merges baseline environment variables that the
|
||||
// integration tests rely on into env, without overwriting values supplied
|
||||
// by the test scenario itself. Tests can clear a default by setting it to
|
||||
// the empty string in their scenario table.
|
||||
func applyDefaultEnv(env map[string]string) map[string]string {
|
||||
if env == nil {
|
||||
env = make(map[string]string)
|
||||
}
|
||||
defaults := map[string]string{
|
||||
"CHROMIUM_ALLOW_LIST": integrationAllowList,
|
||||
"API_DOWNLOAD_FROM_ALLOW_LIST": integrationAllowList,
|
||||
"WEBHOOK_ALLOW_LIST": integrationAllowList,
|
||||
}
|
||||
for k, v := range defaults {
|
||||
if _, ok := env[k]; !ok {
|
||||
env[k] = v
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func startGotenbergContainer(ctx context.Context, env map[string]string) (*testcontainers.DockerNetwork, testcontainers.Container, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
env = applyDefaultEnv(env)
|
||||
|
||||
n, err := network.New(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create Gotenberg container network: %w", err)
|
||||
@@ -96,7 +139,7 @@ func execCommandInIntegrationToolsContainer(ctx context.Context, cmd []string, p
|
||||
}
|
||||
}(c, ctx)
|
||||
|
||||
_, output, err := c.Exec(ctx, cmd)
|
||||
_, output, err := c.Exec(ctx, cmd, exec.Multiplexed())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("exec %q: %w", cmd, err)
|
||||
}
|
||||
@@ -109,7 +152,7 @@ func execCommandInIntegrationToolsContainer(ctx context.Context, cmd []string, p
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func containerHttpEndpoint(ctx context.Context, container testcontainers.Container, port nat.Port) (string, error) {
|
||||
func containerHttpEndpoint(ctx context.Context, container testcontainers.Container, port string) (string, error) {
|
||||
ip, err := container.Host(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get container IP: %w", err)
|
||||
|
||||
@@ -1262,6 +1262,68 @@ func (s *scenario) thePdfsShouldHaveEmbeddedFile(ctx context.Context, kind, shou
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *scenario) thePdfsShouldHaveEmbeddedFileWithRelationship(ctx context.Context, kind, embed, relationship string) error {
|
||||
dirPath := s.teststoreDir
|
||||
|
||||
_, err := os.Stat(dirPath)
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("directory %q does not exist", dirPath)
|
||||
}
|
||||
|
||||
var paths []string
|
||||
err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error {
|
||||
if pathErr != nil {
|
||||
return pathErr
|
||||
}
|
||||
if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("walk %q: %w", dirPath, err)
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
cmd := []string{
|
||||
"verapdf",
|
||||
"--off",
|
||||
"--loglevel",
|
||||
"0",
|
||||
"--extract",
|
||||
"embeddedFile",
|
||||
filepath.Base(path),
|
||||
}
|
||||
|
||||
output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("exec %q: %w", cmd, err)
|
||||
}
|
||||
|
||||
fileNameTag := fmt.Sprintf("<fileName>%s</fileName>", embed)
|
||||
relationshipTag := fmt.Sprintf("<afRelationship>%s</afRelationship>", relationship)
|
||||
|
||||
blocks := strings.Split(output, "</embeddedFile>")
|
||||
found := false
|
||||
for _, block := range blocks {
|
||||
if !strings.Contains(block, fileNameTag) {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(block, relationshipTag) {
|
||||
return fmt.Errorf("embedded file %q missing afRelationship %q", embed, relationship)
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
|
||||
if !found {
|
||||
return fmt.Errorf("embedded file %q not found in verapdf output", embed)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func InitializeScenario(ctx *godog.ScenarioContext) {
|
||||
s := &scenario{}
|
||||
ctx.Before(func(ctx context.Context, sc *godog.Scenario) (context.Context, error) {
|
||||
@@ -1300,6 +1362,7 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
|
||||
ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) be flatten$`, s.thePdfsShouldBeFlatten)
|
||||
ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) be encrypted`, s.thePdfsShouldBeEncrypted)
|
||||
ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) have the "([^"]*)" file embedded$`, s.thePdfsShouldHaveEmbeddedFile)
|
||||
ctx.Then(`^the (response|webhook request) PDF\(s\) should have the "([^"]*)" file embedded with relationship "([^"]*)"$`, s.thePdfsShouldHaveEmbeddedFileWithRelationship)
|
||||
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) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage)
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<p>Asset test</p>
|
||||
<img src="image.png" alt="Test image" />
|
||||
<img src="image.png" width="100px" height="100px" alt="Test image" />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
6
test/integration/testdata/pem/README.md
vendored
6
test/integration/testdata/pem/README.md
vendored
@@ -1,13 +1,13 @@
|
||||
To generate a valid certificate and private key use the following command:
|
||||
Generate a valid certificate and private key:
|
||||
|
||||
```bash
|
||||
# In OpenSSL ≥ 1.1.1
|
||||
# OpenSSL 1.1.1+
|
||||
openssl req -x509 -newkey rsa:4096 -sha256 -days 9999 -nodes \
|
||||
-keyout key.pem -out cert.pem -subj "/CN=localhost" \
|
||||
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1"
|
||||
```
|
||||
|
||||
To check a certificate use the following command:
|
||||
Check a certificate:
|
||||
|
||||
```bash
|
||||
openssl x509 -in cert.pem -text
|
||||
|
||||
Reference in New Issue
Block a user